From 65b944c24edf387dd34dcff1094fc15a58cfc57e Mon Sep 17 00:00:00 2001 From: "Jakub A. W" Date: Mon, 24 Aug 2026 14:30:01 +0200 Subject: [PATCH 1/2] feat(config): disable model list downloads with MODEL_LIST_URL=off MODEL_LIST_URL=off (case-insensitive) turns the external model catalog download off entirely, for fully air-gapped installs. A sentinel is used instead of an empty value because the env overlay skips empty values (so an unset-but-templated variable cannot disable it by accident), and it works identically via cache.model.model_list.url in config.yaml. Startup logs that downloads are disabled; models keep provider-reported and configured metadata only. --- .env.template | 10 +++-- config/config_test.go | 72 +++++++++++++++++++++++++++++++- config/env.go | 17 +++++++- docs/advanced/model-metadata.mdx | 6 +-- docs/guides/production.mdx | 15 ++++--- internal/providers/init.go | 4 +- 6 files changed, 108 insertions(+), 16 deletions(-) diff --git a/.env.template b/.env.template index 36841b6ab..f1cdf8a53 100644 --- a/.env.template +++ b/.env.template @@ -185,10 +185,12 @@ # GOMODEL_CACHE_DIR=.cache # External model metadata registry (provides pricing, capabilities, context window, etc.) -# Default: ENTERPILOT/ai-model-list on GitHub. Point this at an internal mirror -# for air-gapped installs. Setting it to an empty string here does NOT disable -# the fetch (empty env values are skipped, so the default survives) -- to -# disable it, set cache.model.model_list.url: "" in config.yaml. +# Default: ENTERPILOT/ai-model-list on GitHub. Point this at an internal mirror, +# or set MODEL_LIST_URL=off to disable the download entirely for fully +# air-gapped installs -- models then keep provider-reported and configured +# metadata only, so declare pricing in config.yaml if you need cost tracking +# and budgets. (Setting it to an empty string does NOT disable the fetch: +# empty env values are skipped, so the default survives.) # MODEL_LIST_URL=https://raw.githubusercontent.com/ENTERPILOT/ai-model-list/refs/heads/main/models.min.json # Model Access Configuration diff --git a/config/config_test.go b/config/config_test.go index f3ac8b3ed..beb1283f6 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -45,7 +45,7 @@ func clearAllConfigEnvVars(t *testing.T) { for _, key := range []string{ "CONFIG_STRICT", "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", + "GOMODEL_CACHE_DIR", "CACHE_REFRESH_INTERVAL", "MODEL_LIST_URL", "REDIS_URL", "REDIS_KEY_MODELS", "REDIS_KEY_RESPONSES", "REDIS_TTL_MODELS", "REDIS_TTL_RESPONSES", "RESPONSE_CACHE_SIMPLE_ENABLED", "SEMANTIC_CACHE_ENABLED", "SEMANTIC_CACHE_THRESHOLD", "SEMANTIC_CACHE_TTL", "SEMANTIC_CACHE_MAX_CONV_MESSAGES", @@ -1433,6 +1433,76 @@ func TestLoad_EnvOverridesDefaults(t *testing.T) { }) } +func TestLoad_ModelListURLEnv(t *testing.T) { + const defaultURL = "https://raw.githubusercontent.com/ENTERPILOT/ai-model-list/refs/heads/main/models.min.json" + + tests := []struct { + name string + set bool + value string + want string + }{ + {name: "UnsetKeepsDefault", set: false, want: defaultURL}, + {name: "MirrorOverridesDefault", set: true, value: "https://mirror.internal/models.min.json", want: "https://mirror.internal/models.min.json"}, + {name: "EmptyIsSkippedLikeAnyEnvVar", set: true, value: "", want: defaultURL}, + {name: "OffDisablesDownloads", set: true, value: "off", want: ""}, + {name: "OffIsCaseInsensitive", set: true, value: "OFF", want: ""}, + {name: "OffTrimsWhitespace", set: true, value: " off ", want: ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + clearAllConfigEnvVars(t) + withTempDir(t, func(_ string) { + if tt.set { + t.Setenv("MODEL_LIST_URL", tt.value) + } + result, err := Load() + if err != nil { + t.Fatalf("Load() failed: %v", err) + } + if got := result.Config.Cache.Model.ModelList.URL; got != tt.want { + t.Errorf("Cache.Model.ModelList.URL = %q, want %q", got, tt.want) + } + }) + }) + } + + t.Run("EnvOffWinsOverConfigYAML", func(t *testing.T) { + clearAllConfigEnvVars(t) + withTempDir(t, func(dir string) { + yaml := "cache:\n model:\n model_list:\n url: \"https://mirror.internal/models.min.json\"\n" + if err := os.WriteFile(filepath.Join(dir, "config.yaml"), []byte(yaml), 0o644); err != nil { + t.Fatalf("failed to write config.yaml: %v", err) + } + t.Setenv("MODEL_LIST_URL", "off") + result, err := Load() + if err != nil { + t.Fatalf("Load() failed: %v", err) + } + if got := result.Config.Cache.Model.ModelList.URL; got != "" { + t.Errorf("Cache.Model.ModelList.URL = %q, want empty (env off wins over config.yaml)", got) + } + }) + }) + + t.Run("ConfigYAMLOffDisablesDownloads", func(t *testing.T) { + clearAllConfigEnvVars(t) + withTempDir(t, func(dir string) { + yaml := "cache:\n model:\n model_list:\n url: \"off\"\n" + if err := os.WriteFile(filepath.Join(dir, "config.yaml"), []byte(yaml), 0o644); err != nil { + t.Fatalf("failed to write config.yaml: %v", err) + } + result, err := Load() + if err != nil { + t.Fatalf("Load() failed: %v", err) + } + if got := result.Config.Cache.Model.ModelList.URL; got != "" { + t.Errorf("Cache.Model.ModelList.URL = %q, want empty (yaml off disables)", got) + } + }) + }) +} + func TestLoad_ProviderFromYAML(t *testing.T) { clearAllConfigEnvVars(t) diff --git a/config/env.go b/config/env.go index 295cd4412..d897a4381 100644 --- a/config/env.go +++ b/config/env.go @@ -12,7 +12,22 @@ import ( // applyEnvOverrides walks cfg's struct fields and applies env var overrides // based on `env` struct tags. Maps are skipped. func applyEnvOverrides(cfg *Config) error { - return applyEnvOverridesValue(reflect.ValueOf(cfg).Elem()) + if err := applyEnvOverridesValue(reflect.ValueOf(cfg).Elem()); err != nil { + return err + } + normalizeModelListURL(cfg) + return nil +} + +// normalizeModelListURL maps the sentinel "off" (case-insensitive) to an empty +// model list URL, disabling catalog downloads for air-gapped installs. A +// sentinel is used because empty env values are skipped by the generic overlay +// (so MODEL_LIST_URL="" cannot override the default), and it works identically +// when set via config.yaml. +func normalizeModelListURL(cfg *Config) { + if strings.EqualFold(strings.TrimSpace(cfg.Cache.Model.ModelList.URL), "off") { + cfg.Cache.Model.ModelList.URL = "" + } } // hasEnvDescendants reports whether t (a struct type) contains any field (at diff --git a/docs/advanced/model-metadata.mdx b/docs/advanced/model-metadata.mdx index 51645b38b..1cc6b8cd7 100644 --- a/docs/advanced/model-metadata.mdx +++ b/docs/advanced/model-metadata.mdx @@ -69,6 +69,6 @@ flowchart LR If the catalog fetch fails or the deployment is air-gapped, the gateway runs normally — only the catalog-supplied defaults (including catalog pricing) are missing. Pricing overrides, `config.yaml` metadata, provider discovery signals, -and the ID heuristic still apply. Mirror `MODEL_LIST_URL` internally or declare -metadata in `config.yaml`; see [Production guide](/guides/production) for -details. +and the ID heuristic still apply. Mirror `MODEL_LIST_URL` internally, or set +`MODEL_LIST_URL=off` to turn the download off entirely and declare metadata in +`config.yaml`; see [Production guide](/guides/production) for details. diff --git a/docs/guides/production.mdx b/docs/guides/production.mdx index 0ee64ce65..d7369d97f 100644 --- a/docs/guides/production.mdx +++ b/docs/guides/production.mdx @@ -201,10 +201,12 @@ normally. What you lose is metadata enrichment. - Setting `MODEL_LIST_URL=""` does **not** disable the fetch. Empty environment - values are skipped when overrides are applied, so the compiled-in default URL - survives. To disable it, set `cache.model.model_list.url: ""` in - `config.yaml`. To redirect it, point `MODEL_LIST_URL` at an internal mirror. + To disable the fetch entirely — for fully air-gapped installs — set + `MODEL_LIST_URL=off` (or `cache.model.model_list.url: "off"` in + `config.yaml`). Setting the variable to an empty string does **not** + disable it: empty env values are skipped, so the default URL survives. + To redirect the fetch instead, point `MODEL_LIST_URL` at an internal + mirror. If a Bedrock provider is configured, the AWS SDK may also probe the link-local @@ -377,5 +379,6 @@ if the file size matters. `terminationGracePeriodSeconds` above 30. - Enable metrics and alert on `gomodel_circuit_breaker_state` and on `usage log buffer full`. -- For air-gapped installs, mirror `MODEL_LIST_URL` or declare model pricing in - config so cost tracking and budgets keep working. +- For air-gapped installs, mirror `MODEL_LIST_URL` (or disable it with + `MODEL_LIST_URL=off`) and declare model pricing in config so cost tracking + and budgets keep working. diff --git a/internal/providers/init.go b/internal/providers/init.go index 725c905b5..54c507162 100644 --- a/internal/providers/init.go +++ b/internal/providers/init.go @@ -128,7 +128,9 @@ func Init(ctx context.Context, result *config.LoadResult, factory *ProviderFacto // Fetch model list in background (best-effort, non-blocking) modelListURL := result.Config.Cache.Model.ModelList.URL - if modelListURL != "" { + if modelListURL == "" { + slog.Info("model list downloads disabled; models keep provider-reported and configured metadata only") + } else { go func() { fetchCtx, cancel := context.WithTimeout(ctx, 45*time.Second) defer cancel() From 7c54fdb0667155ff9f8959b2fe9203448e5720f6 Mon Sep 17 00:00:00 2001 From: "Jakub A. W" Date: Mon, 24 Aug 2026 16:26:58 +0200 Subject: [PATCH 2/2] docs(config): correct which metadata survives disabled model list downloads Cached catalog data restored from the model cache and heuristic-derived modes still apply when downloads are off; the log line and .env.template no longer claim provider-reported and configured metadata are the only sources. --- .env.template | 9 +++++---- internal/providers/init.go | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/.env.template b/.env.template index f1cdf8a53..c839fa210 100644 --- a/.env.template +++ b/.env.template @@ -187,10 +187,11 @@ # External model metadata registry (provides pricing, capabilities, context window, etc.) # Default: ENTERPILOT/ai-model-list on GitHub. Point this at an internal mirror, # or set MODEL_LIST_URL=off to disable the download entirely for fully -# air-gapped installs -- models then keep provider-reported and configured -# metadata only, so declare pricing in config.yaml if you need cost tracking -# and budgets. (Setting it to an empty string does NOT disable the fetch: -# empty env values are skipped, so the default survives.) +# air-gapped installs -- models then keep provider-reported, configured, +# heuristic-derived, and any previously cached catalog metadata, so declare +# pricing in config.yaml if you need cost tracking and budgets. (Setting it +# to an empty string does NOT disable the fetch: empty env values are +# skipped, so the default survives.) # MODEL_LIST_URL=https://raw.githubusercontent.com/ENTERPILOT/ai-model-list/refs/heads/main/models.min.json # Model Access Configuration diff --git a/internal/providers/init.go b/internal/providers/init.go index 54c507162..3f2145452 100644 --- a/internal/providers/init.go +++ b/internal/providers/init.go @@ -129,7 +129,7 @@ func Init(ctx context.Context, result *config.LoadResult, factory *ProviderFacto // Fetch model list in background (best-effort, non-blocking) modelListURL := result.Config.Cache.Model.ModelList.URL if modelListURL == "" { - slog.Info("model list downloads disabled; models keep provider-reported and configured metadata only") + slog.Info("model list downloads disabled; models rely on provider-reported, configured, and any previously cached catalog metadata") } else { go func() { fetchCtx, cancel := context.WithTimeout(ctx, 45*time.Second)