Module: Aireview::ConfigLoader
- Defined in:
- lib/aireview/config_loader.rb
Overview
Builds a Config from layers: built-in values, image defaults (AIREVIEW_DEFAULTS), the project's .aireview.yml (searched upwards from cwd), env. Everything that knows environment variable names and file formats lives here; Config only answers questions about merged data.
Constant Summary collapse
- ENV_MAPPING =
{ 'gitlab_url' => 'GITLAB_URL', 'gitlab_token' => 'GITLAB_TOKEN', 'jira_url' => 'JIRA_URL', 'jira_login' => 'JIRA_LOGIN', 'jira_password' => 'JIRA_PASSWORD', 'review_language' => 'REVIEW_LANGUAGE', 'review_mode' => 'REVIEW_MODE', 'llm_api_base' => 'LLM_API_BASE', 'ollama_api_base' => 'OLLAMA_API_BASE', 'llm_http_proxy' => 'LLM_HTTP_PROXY', 'jev_api_key' => 'JEV_API_KEY' }.freeze
- PROVIDER_KEY_MAPPING =
{ 'gemini' => 'GEMINI_API_KEY', 'openai' => 'OPENAI_API_KEY', 'anthropic' => 'ANTHROPIC_API_KEY', 'openrouter' => 'OPENROUTER_API_KEY' }.freeze
- PROVIDER_KEYS_MAPPING =
{ 'gemini' => 'GEMINI_API_KEYS', 'openai' => 'OPENAI_API_KEYS', 'anthropic' => 'ANTHROPIC_API_KEYS', 'openrouter' => 'OPENROUTER_API_KEYS' }.freeze
- CONTEXT_ENV =
{ 'max_diff_chars' => 'MAX_DIFF_CHARS', 'max_mr_description_chars' => 'MAX_MR_DESCRIPTION_CHARS', 'max_jira_description_chars' => 'MAX_JIRA_DESCRIPTION_CHARS', 'max_jira_comment_chars' => 'MAX_JIRA_COMMENT_CHARS' }.freeze
- LLM_ENV =
%w[ LLM_PROVIDER LLM_TEMPERATURE LLM_TIMEOUT LLM_MAX_PROMPT_CHARS LLM_TIME_BUDGET LLM_OVERLOADED_QUARANTINE LLM_MODELS LLM_CRITIQUE_RANK LLM_CRITIQUE_ALLOW_WEAKER LLM_CRITIQUE_ENGINE LLM_JEV_SHADOW LLM_JEV_MODEL LLM_JEV_FALLBACK LLM_JEV_KEEP_ABOVE ].freeze
- LLM_STAGE_ENV_SUFFIXES =
%w[PROVIDER MODEL TEMPERATURE MAX_PROMPT_CHARS FALLBACK_MODEL START].freeze
- IMAGE_DEFAULTS_ENV =
'AIREVIEW_DEFAULTS'
Class Method Summary collapse
- .context_env_config(env) ⇒ Object
- .discover_file(cwd, basename) ⇒ Object
- .env_config(env) ⇒ Object
-
.env_names ⇒ Object
Every environment variable the loader reads.
-
.fallback_models_env_config(env, stage) ⇒ Object
LLM_GENERATE_FALLBACK_MODEL=gemini-3.8-flash (or a comma-separated list) — the provider is separated by a slash because Ollama tags contain a colon.
- .file_layer(file_path) ⇒ Object
- .generic_api_key_env_config(env) ⇒ Object
-
.image_defaults_layer(env) ⇒ Object
Image defaults: the Dockerfile sets the path through AIREVIEW_DEFAULTS.
-
.jev_env_config(env) ⇒ Object
LLM_JEV_SHADOW=true|false, LLM_JEV_MODEL — a pinned Jev version, LLM_JEV_FALLBACK=model|fail, LLM_JEV_KEEP_ABOVE — the keep threshold.
- .llm_env_config(env) ⇒ Object
- .llm_stage_env_config(env, stage) ⇒ Object
- .load(config_path: nil, cwd: Dir.pwd, env: ENV, logger: Logger.new($stderr)) ⇒ Object
- .load_dotenv(cwd) ⇒ Object
- .mapped_env_config(env) ⇒ Object
- .parse_boolean(value, name) ⇒ Object
- .parse_float(value) ⇒ Object
-
.parse_integer(value, name) ⇒ Object
A limit that failed to parse must not silently fall back to the default: the request would go to a model with a window it does not have.
-
.pool_env_config(env) ⇒ Object
LLM_MODELS=gemini/gemini-3.8-flash,gemini/gemini-3.7-flash; LLM_GENERATE_START / LLM_CRITIQUE_START — a model from the pool; LLM_CRITIQUE_RANK, LLM_CRITIQUE_ALLOW_WEAKER=true|false.
- .provider_key_env_config(env) ⇒ Object
- .provider_keys_env_config(env) ⇒ Object
- .read_yaml(path) ⇒ Object
Class Method Details
.context_env_config(env) ⇒ Object
211 212 213 214 215 216 217 |
# File 'lib/aireview/config_loader.rb', line 211 def context_env_config(env) context = CONTEXT_ENV.each_with_object({}) do |(key, env_key), config| value = parse_integer(env[env_key], env_key) config[key] = value unless value.nil? end context.empty? ? {} : {'context' => context} end |
.discover_file(cwd, basename) ⇒ Object
95 96 97 98 99 100 101 102 103 104 105 106 107 108 |
# File 'lib/aireview/config_loader.rb', line 95 def discover_file(cwd, basename) current = Pathname.new(cwd). loop do candidate = current.join(basename) return candidate.to_s if candidate.file? break if current.root? current = current.parent end File.join(cwd, basename) end |
.env_config(env) ⇒ Object
131 132 133 134 135 136 137 138 |
# File 'lib/aireview/config_loader.rb', line 131 def env_config(env) mapped_env_config(env) .merge('llm' => Aireview::Utils.deep_merge(llm_env_config(env), pool_env_config(env))) .merge(context_env_config(env)) .merge(provider_key_env_config(env)) .merge(provider_keys_env_config(env)) .merge(generic_api_key_env_config(env)) end |
.env_names ⇒ Object
Every environment variable the loader reads. The CI template passes them into the container by name: a project variable missing from this list never reaches the review.
77 78 79 80 81 82 83 84 85 |
# File 'lib/aireview/config_loader.rb', line 77 def env_names stage_env = STAGES.flat_map do |stage| LLM_STAGE_ENV_SUFFIXES.map { |suffix| "LLM_#{stage.upcase}_#{suffix}" } end [ *ENV_MAPPING.values, *PROVIDER_KEY_MAPPING.values, *PROVIDER_KEYS_MAPPING.values, 'LLM_API_KEY', *LLM_ENV, *stage_env, *CONTEXT_ENV.values ].uniq end |
.fallback_models_env_config(env, stage) ⇒ Object
LLM_GENERATE_FALLBACK_MODEL=gemini-3.8-flash (or a comma-separated list) — the provider is separated by a slash because Ollama tags contain a colon.
185 186 187 188 189 190 |
# File 'lib/aireview/config_loader.rb', line 185 def fallback_models_env_config(env, stage) value = env["LLM_#{stage}_FALLBACK_MODEL"] return nil if Aireview::Utils.blank?(value) value.split(',').map(&:strip).reject(&:empty?).map { |item| ModelCandidate.parse_item(item) } end |
.file_layer(file_path) ⇒ Object
121 122 123 124 125 |
# File 'lib/aireview/config_loader.rb', line 121 def file_layer(file_path) return nil unless File.file?(file_path) ConfigLayers::Layer.new(name: ConfigLayers::FILE_LAYER, path: file_path, data: read_yaml(file_path)) end |
.generic_api_key_env_config(env) ⇒ Object
233 234 235 236 237 238 |
# File 'lib/aireview/config_loader.rb', line 233 def generic_api_key_env_config(env) api_key = env['LLM_API_KEY'] return {} if Aireview::Utils.blank?(api_key) {'llm_api_key' => api_key} end |
.image_defaults_layer(env) ⇒ Object
Image defaults: the Dockerfile sets the path through AIREVIEW_DEFAULTS. A path that is set but missing means a broken image; better to learn that at once.
113 114 115 116 117 118 119 |
# File 'lib/aireview/config_loader.rb', line 113 def image_defaults_layer(env) path = env[IMAGE_DEFAULTS_ENV] return nil if Aireview::Utils.blank?(path) raise ConfigError, "#{IMAGE_DEFAULTS_ENV} points to a missing file: #{path}" unless File.file?(path) ConfigLayers::Layer.new(name: ConfigLayers::IMAGE_LAYER, path: path, data: read_yaml(path)) end |
.jev_env_config(env) ⇒ Object
LLM_JEV_SHADOW=true|false, LLM_JEV_MODEL — a pinned Jev version, LLM_JEV_FALLBACK=model|fail, LLM_JEV_KEEP_ABOVE — the keep threshold.
163 164 165 166 167 168 169 170 171 |
# File 'lib/aireview/config_loader.rb', line 163 def jev_env_config(env) { 'shadow' => parse_boolean(env['LLM_JEV_SHADOW'], 'LLM_JEV_SHADOW'), 'model' => Aireview::Utils.presence(env['LLM_JEV_MODEL']), 'fallback' => Aireview::Utils.presence(env['LLM_JEV_FALLBACK']), # Validated by ConfigJev: a typo must fail, not fall back to the default. 'keep_above' => Aireview::Utils.presence(env['LLM_JEV_KEEP_ABOVE']) }.compact end |
.llm_env_config(env) ⇒ Object
147 148 149 150 151 152 153 154 155 156 157 158 159 |
# File 'lib/aireview/config_loader.rb', line 147 def llm_env_config(env) { 'provider' => env['LLM_PROVIDER'], 'temperature' => parse_float(env['LLM_TEMPERATURE']), 'timeout' => parse_float(env['LLM_TIMEOUT']), 'max_prompt_chars' => parse_integer(env['LLM_MAX_PROMPT_CHARS'], 'LLM_MAX_PROMPT_CHARS'), 'time_budget' => parse_integer(env['LLM_TIME_BUDGET'], 'LLM_TIME_BUDGET'), 'overloaded_quarantine' => parse_integer(env['LLM_OVERLOADED_QUARANTINE'], 'LLM_OVERLOADED_QUARANTINE'), 'generate' => llm_stage_env_config(env, 'GENERATE'), 'critique' => llm_stage_env_config(env, 'CRITIQUE'), 'jev' => jev_env_config(env) }.compact.reject { |key, value| %w[generate critique jev].include?(key) && value.empty? } end |
.llm_stage_env_config(env, stage) ⇒ Object
173 174 175 176 177 178 179 180 181 |
# File 'lib/aireview/config_loader.rb', line 173 def llm_stage_env_config(env, stage) { 'provider' => env["LLM_#{stage}_PROVIDER"], 'model' => env["LLM_#{stage}_MODEL"], 'temperature' => parse_float(env["LLM_#{stage}_TEMPERATURE"]), 'max_prompt_chars' => parse_integer(env["LLM_#{stage}_MAX_PROMPT_CHARS"], "LLM_#{stage}_MAX_PROMPT_CHARS"), 'fallbacks' => fallback_models_env_config(env, stage) }.compact end |
.load(config_path: nil, cwd: Dir.pwd, env: ENV, logger: Logger.new($stderr)) ⇒ Object
60 61 62 63 64 65 66 67 68 69 70 71 72 |
# File 'lib/aireview/config_loader.rb', line 60 def load(config_path: nil, cwd: Dir.pwd, env: ENV, logger: Logger.new($stderr)) load_dotenv(cwd) file_path = config_path ? File.(config_path, cwd) : discover_file(cwd, '.aireview.yml') layers = [ ConfigLayers::Layer.new(name: ConfigLayers::BUILT_IN_LAYER, data: Config::DEFAULTS), image_defaults_layer(env), file_layer(file_path), ConfigLayers::Layer.new(name: ConfigLayers::ENV_LAYER, data: env_config(env)) ].compact Config.new(config_path: File.file?(file_path) ? file_path : nil, logger: logger, layers: layers) end |
.load_dotenv(cwd) ⇒ Object
87 88 89 90 91 92 93 |
# File 'lib/aireview/config_loader.rb', line 87 def load_dotenv(cwd) require 'dotenv' dotenv_path = discover_file(cwd, '.env') Dotenv.load(dotenv_path) if File.file?(dotenv_path) rescue LoadError nil end |
.mapped_env_config(env) ⇒ Object
140 141 142 143 144 145 |
# File 'lib/aireview/config_loader.rb', line 140 def mapped_env_config(env) ENV_MAPPING.each_with_object({}) do |(key, env_key), config| value = env[env_key] config[key] = value unless Aireview::Utils.blank?(value) end end |
.parse_boolean(value, name) ⇒ Object
258 259 260 261 262 263 264 |
# File 'lib/aireview/config_loader.rb', line 258 def parse_boolean(value, name) return nil if Aireview::Utils.blank?(value) return true if %w[true 1 yes].include?(value.to_s.downcase) return false if %w[false 0 no].include?(value.to_s.downcase) raise ConfigError, "#{name} must be true or false, got #{value.inspect}" end |
.parse_float(value) ⇒ Object
240 241 242 243 244 245 246 |
# File 'lib/aireview/config_loader.rb', line 240 def parse_float(value) return nil if Aireview::Utils.blank?(value) Float(value) rescue ArgumentError nil end |
.parse_integer(value, name) ⇒ Object
A limit that failed to parse must not silently fall back to the default: the request would go to a model with a window it does not have.
250 251 252 253 254 255 256 |
# File 'lib/aireview/config_loader.rb', line 250 def parse_integer(value, name) return nil if Aireview::Utils.blank?(value) Integer(value.to_s, 10) rescue ArgumentError raise ConfigError, "#{name} must be an integer, got #{value.inspect}" end |
.pool_env_config(env) ⇒ Object
LLM_MODELS=gemini/gemini-3.8-flash,gemini/gemini-3.7-flash; LLM_GENERATE_START / LLM_CRITIQUE_START — a model from the pool; LLM_CRITIQUE_RANK, LLM_CRITIQUE_ALLOW_WEAKER=true|false.
195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 |
# File 'lib/aireview/config_loader.rb', line 195 def pool_env_config(env) models = env['LLM_MODELS'].to_s.split(',').map(&:strip).reject(&:empty?).map do |item| ModelCandidate.parse_item(item) end { 'models' => models.empty? ? nil : models, 'generate' => {'start' => env['LLM_GENERATE_START']}.compact, 'critique' => { 'start' => env['LLM_CRITIQUE_START'], 'engine' => Aireview::Utils.presence(env['LLM_CRITIQUE_ENGINE']), 'rank' => env['LLM_CRITIQUE_RANK'], 'allow_weaker' => parse_boolean(env['LLM_CRITIQUE_ALLOW_WEAKER'], 'LLM_CRITIQUE_ALLOW_WEAKER') }.compact }.compact.reject { |_, value| value.is_a?(Hash) && value.empty? } end |
.provider_key_env_config(env) ⇒ Object
219 220 221 222 223 224 |
# File 'lib/aireview/config_loader.rb', line 219 def provider_key_env_config(env) PROVIDER_KEY_MAPPING.each_with_object({}) do |(provider, env_key), config| value = env[env_key] config["#{provider}_api_key"] = value unless Aireview::Utils.blank?(value) end end |
.provider_keys_env_config(env) ⇒ Object
226 227 228 229 230 231 |
# File 'lib/aireview/config_loader.rb', line 226 def provider_keys_env_config(env) PROVIDER_KEYS_MAPPING.each_with_object({}) do |(provider, env_key), config| keys = env[env_key].to_s.split(',').map(&:strip).reject(&:empty?) config["#{provider}_api_keys"] = keys unless keys.empty? end end |
.read_yaml(path) ⇒ Object
127 128 129 |
# File 'lib/aireview/config_loader.rb', line 127 def read_yaml(path) Aireview::Utils.normalize_hash(YAML.load_file(path) || {}) end |