aireview 0.3.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,65 +1,34 @@
1
1
  # frozen_string_literal: true
2
+ require_relative 'stages'
3
+ require_relative 'errors'
4
+ require_relative 'utils'
5
+ require_relative 'model_candidate'
6
+ require_relative 'stage_chains'
7
+ require_relative 'model_pool'
2
8
 
3
9
  module Aireview
4
- # Резервы на случай, когда основная модель лежит или у ключа кончилась
5
- # квота: цепочка моделей на стадию и список ключей на провайдера. Модели
6
- # задаются в .aireview.yml или env, ключи — только в env.
10
+ # Reserves for when the primary model is down or a key is out of quota:
11
+ # the routing plan (see StageChains and ModelPool) and the list of keys
12
+ # per provider. Models are set in .aireview.yml or the environment, keys
13
+ # only in the environment.
7
14
  module ConfigFallbacks
8
- ModelCandidate = Struct.new(:provider, :model, :max_prompt_chars, keyword_init: true) do
9
- def to_s
10
- "#{provider}/#{model}"
11
- end
12
- end
13
-
14
- KNOWN_PROVIDERS = %w[gemini ollama].freeze
15
15
  KEYLESS_PROVIDERS = %w[ollama].freeze
16
- PROVIDER_KEYS_MAPPING = {
17
- 'gemini' => 'GEMINI_API_KEYS'
18
- }.freeze
19
16
  DEFAULT_TIME_BUDGET = 1_800
17
+ DEFAULT_OVERLOADED_QUARANTINE = 120
20
18
 
21
- module ClassMethods
22
- # LLM_GENERATE_FALLBACK_MODEL=gemini-3.8-flash (или список через запятую) —
23
- # провайдер отделён слэшем, потому что теги Ollama содержат двоеточие.
24
- def fallback_models_env_config(env, stage)
25
- value = env["LLM_#{stage}_FALLBACK_MODEL"]
26
- return nil if Aireview::Utils.blank?(value)
27
-
28
- value.split(',').map(&:strip).reject(&:empty?).map { |item| parse_fallback_item(item) }
29
- end
30
-
31
- def parse_fallback_item(item)
32
- provider, model = item.split('/', 2)
33
- return {'provider' => provider, 'model' => model} if model && KNOWN_PROVIDERS.include?(provider)
34
-
35
- {'model' => item}
36
- end
37
-
38
- def provider_keys_env_config(env)
39
- PROVIDER_KEYS_MAPPING.each_with_object({}) do |(provider, env_key), config|
40
- keys = env[env_key].to_s.split(',').map(&:strip).reject(&:empty?)
41
- config["#{provider}_api_keys"] = keys unless keys.empty?
42
- end
43
- end
19
+ # The routing plan is built once: a shared pool when llm.models is set,
20
+ # independent stage chains otherwise. A stage with a model of its own
21
+ # inside the pool is an independent chain.
22
+ def routing
23
+ @routing ||= build_routing
44
24
  end
45
25
 
46
- # Первый элемент — основная модель стадии, дальше запасные в порядке
47
- # обхода. Запасная без провайдера наследует провайдера стадии, без
48
- # max_prompt_chars — лимит стадии.
49
26
  def stage_chain(stage)
50
- stage = stage.to_s
51
- primary = ModelCandidate.new(
52
- provider: public_send("#{stage}_provider"),
53
- model: public_send("#{stage}_model"),
54
- max_prompt_chars: max_prompt_chars(stage)
55
- )
56
- return [primary] if fallbacks_disabled?
57
-
58
- [primary, *fallback_candidates(stage, primary)]
27
+ routing.chain(stage)
59
28
  end
60
29
 
61
- # Ключи в порядке предпочтения; для провайдера без ключей — один nil,
62
- # чтобы обход цепочки не зависел от провайдера.
30
+ # Keys in order of preference; a single nil for a keyless provider, so
31
+ # that walking the chain does not depend on the provider.
63
32
  def provider_api_keys(provider)
64
33
  provider = provider.to_s
65
34
  return [nil] if KEYLESS_PROVIDERS.include?(provider)
@@ -77,36 +46,78 @@ module Aireview
77
46
  stage_chain(stage).drop(1).map(&:to_s)
78
47
  end
79
48
 
80
- # Только число ключей на провайдера — для --dry-run; значения наружу не
81
- # выходят.
49
+ # Only the number of keys per provider, for --dry-run; the values never
50
+ # leave.
82
51
  def api_key_counts(stages)
83
52
  providers = stages.flat_map { |stage| stage_chain(stage).map(&:provider) }.uniq
84
53
  providers.reject { |provider| KEYLESS_PROVIDERS.include?(provider.to_s) }
85
54
  .to_h { |provider| [provider, provider_api_keys(provider).size] }
86
55
  end
87
56
 
88
- # Общий потолок на все LLM-запросы прогона вместе с паузами между
89
- # попытками: цепочка резервов не должна съедать всю CI-джобу.
57
+ # The ceiling for all LLM requests of the run, pauses between attempts
58
+ # included: the chain of reserves must not eat the whole CI job.
90
59
  def llm_time_budget
91
60
  positive_integer!(dig('llm', 'time_budget') || DEFAULT_TIME_BUDGET, 'llm.time_budget')
92
61
  end
93
62
 
94
- private
63
+ # For how many seconds an overloaded or hung model is skipped before the
64
+ # router tries it again.
65
+ def overloaded_quarantine
66
+ positive_integer!(dig('llm', 'overloaded_quarantine') || DEFAULT_OVERLOADED_QUARANTINE,
67
+ 'llm.overloaded_quarantine')
68
+ end
95
69
 
96
- def fallback_candidates(stage, primary)
97
- Array(dig('llm', stage, 'fallbacks')).each_with_index.map do |item, index|
98
- item = {'model' => item} if item.is_a?(String)
99
- name = "llm.#{stage}.fallbacks[#{index}]"
100
- raise ConfigError, "#{name} must be a model name or a hash with model" unless item.is_a?(Hash)
101
- raise ConfigError, "#{name}.model is required" if Aireview::Utils.blank?(item['model'])
102
-
103
- limit = item['max_prompt_chars']
104
- ModelCandidate.new(
105
- provider: (item['provider'] || primary.provider).to_s,
106
- model: item['model'].to_s,
107
- max_prompt_chars: limit.nil? ? primary.max_prompt_chars : positive_integer!(limit, "#{name}.max_prompt_chars")
108
- )
70
+ def require_models!
71
+ missing = []
72
+ missing << 'llm.generate.model (or LLM_GENERATE_MODEL)' if Aireview::Utils.blank?(generate_model)
73
+ missing << 'llm.critique.model (or LLM_CRITIQUE_MODEL)' if Aireview::Utils.blank?(critique_model)
74
+ raise ConfigError, "LLM models are required: #{missing.join(', ')}" unless missing.empty?
75
+ end
76
+
77
+ # The plan is built whole (pool and critique policy validated) before the
78
+ # first request, not in Critique after a paid-for Generate.
79
+ def require_llm_configuration!
80
+ require_models!
81
+ routing
82
+
83
+ missing_keys = STAGES.flat_map do |stage|
84
+ providers = stage_chain(stage).map(&:provider).uniq.reject { |provider| provider_keys_present?(provider) }
85
+ providers.map { |provider| "#{stage}: API key is required for provider #{provider.inspect}" }
109
86
  end
87
+ raise ConfigError, missing_keys.join(', ') unless missing_keys.empty?
88
+ end
89
+
90
+ private
91
+
92
+ def build_routing
93
+ settings = STAGES.to_h { |stage| [stage, stage_settings(stage)] }
94
+ models = Array(dig('llm', 'models'))
95
+ return StageChains.build(settings, only_primary: fallbacks_disabled?) if models.empty?
96
+
97
+ own = settings.select { |_, stage_settings| Aireview::Utils.present?(stage_settings[:model]) }
98
+ ModelPool.new(
99
+ items: models, provider: llm_provider,
100
+ limits: STAGES.to_h { |stage| [stage, max_prompt_chars(stage)] },
101
+ starts: STAGES.to_h { |stage| [stage, dig('llm', stage, 'start')] },
102
+ inherited_starts: STAGES.select { |stage| start_inherited?(stage) },
103
+ rank: dig('llm', 'critique', 'rank'), allow_weaker: dig('llm', 'critique', 'allow_weaker'),
104
+ own_chains: StageChains.build(own, only_primary: fallbacks_disabled?), only_primary: fallbacks_disabled?
105
+ )
106
+ end
107
+
108
+ def stage_settings(stage)
109
+ {
110
+ provider: stage_setting(stage, 'provider') || llm_provider,
111
+ model: dig('llm', stage, 'model'),
112
+ fallbacks: dig('llm', stage, 'fallbacks'),
113
+ max_prompt_chars: max_prompt_chars(stage)
114
+ }
115
+ end
116
+
117
+ def provider_keys_present?(provider)
118
+ return true if ConfigFallbacks::KEYLESS_PROVIDERS.include?(provider.to_s)
119
+
120
+ provider_api_keys(provider).any? { |key| Aireview::Utils.present?(key) }
110
121
  end
111
122
  end
112
123
  end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+ require_relative 'stages'
3
+ require_relative 'utils'
4
+
5
+ module Aireview
6
+ # Configuration layers in ascending priority: built-in values, image
7
+ # defaults (AIREVIEW_DEFAULTS), the project's .aireview.yml, env, CLI. The
8
+ # layer name is shown by --dry-run, so that it is clear where a model came from.
9
+ module ConfigLayers
10
+ Layer = Struct.new(:name, :path, :data, keyword_init: true)
11
+
12
+ BUILT_IN_LAYER = 'built-in'
13
+ DATA_LAYER = 'config'
14
+ IMAGE_LAYER = 'image defaults'
15
+ FILE_LAYER = '.aireview.yml'
16
+ ENV_LAYER = 'env'
17
+ CLI_LAYER = 'cli'
18
+
19
+ # The name of the layer a value came from; nil — not set anywhere.
20
+ def source_of(*keys)
21
+ layer_of(*keys)&.name
22
+ end
23
+
24
+ # A stage setting resolved layer by layer: in every layer, top down, the
25
+ # stage value (llm.<stage>.<key>) first, then the shared one (llm.<key>).
26
+ # A stage value from the image defaults must not beat a shared value from
27
+ # the project or the environment: LLM_PROVIDER=ollama must switch both stages.
28
+ def stage_setting(stage, key)
29
+ layer = stage_setting_layer(stage, key)
30
+ layer && (Utils.dig(layer.data, 'llm', stage.to_s, key) || Utils.dig(layer.data, 'llm', key))
31
+ end
32
+
33
+ def stage_setting_layer(stage, key)
34
+ stage = stage.to_s
35
+ @layers.reverse.find do |layer|
36
+ !Utils.dig(layer.data, 'llm', stage, key).nil? || !Utils.dig(layer.data, 'llm', key).nil?
37
+ end
38
+ end
39
+
40
+ def stage_provider_source(stage)
41
+ stage_setting_layer(stage, 'provider')&.name
42
+ end
43
+
44
+ # In pool mode the stage model is set by the start or the pool itself, the reserves by the pool.
45
+ def stage_model_source(stage)
46
+ stage = stage.to_s
47
+ return source_of('llm', stage, 'model') unless routing.pool_stage?(stage)
48
+ return source_of('llm', stage, 'start') if routing.start_used?(stage)
49
+
50
+ source_of('llm', 'models')
51
+ end
52
+
53
+ def stage_fallbacks_source(stage)
54
+ stage = stage.to_s
55
+ routing.pool_stage?(stage) ? source_of('llm', 'models') : source_of('llm', stage, 'fallbacks')
56
+ end
57
+
58
+ # The stage start came from a layer below the one that set the pool: the
59
+ # image defaults versus the project's LLM_MODELS.
60
+ def start_inherited?(stage)
61
+ start_layer = layer_of('llm', stage.to_s, 'start')
62
+ models_layer = layer_of('llm', 'models')
63
+ !!(start_layer && models_layer && @layers.index(start_layer) < @layers.index(models_layer))
64
+ end
65
+
66
+ # Configuration and plan warnings; the CLI and --dry-run print them.
67
+ def warnings
68
+ STAGES.flat_map { |stage| stage_provider_warnings(stage) } + routing.warnings
69
+ end
70
+
71
+ # The paths of the file layers, for --dry-run.
72
+ def layer_paths
73
+ @layers.select(&:path).to_h { |layer| [layer.name, layer.path] }
74
+ end
75
+
76
+ # A reserve without a provider inherits the stage provider. When a
77
+ # project overrode the provider above the layer that set the reserves,
78
+ # the inherited reserves silently become "models" of the new provider.
79
+ def stage_provider_warnings(stage)
80
+ stage = stage.to_s
81
+ inherited = inherited_fallback_names(stage)
82
+ return [] if inherited.empty?
83
+
84
+ provider_layer = stage_setting_layer(stage, 'provider')
85
+ fallbacks_layer = layer_of('llm', stage, 'fallbacks')
86
+ return [] unless provider_layer && fallbacks_layer
87
+ return [] if @layers.index(provider_layer) <= @layers.index(fallbacks_layer)
88
+
89
+ provider = public_send("#{stage}_provider")
90
+ ["#{stage}: provider #{provider.inspect} comes from #{provider_layer.name}, " \
91
+ "but fallbacks without an explicit provider come from #{fallbacks_layer.name} " \
92
+ "and now inherit it: #{inherited.join(', ')}"]
93
+ end
94
+
95
+ private
96
+
97
+ def layer_of(*keys)
98
+ @layers.reverse.find { |layer| !Utils.dig(layer.data, *keys).nil? }
99
+ end
100
+
101
+ def inherited_fallback_names(stage)
102
+ Array(dig('llm', stage, 'fallbacks')).filter_map do |item|
103
+ next item.to_s if item.is_a?(String)
104
+ next unless item.is_a?(Hash) && Aireview::Utils.blank?(item['provider'])
105
+
106
+ item['model'].to_s
107
+ end
108
+ end
109
+ end
110
+ end
@@ -1,11 +1,12 @@
1
1
  # frozen_string_literal: true
2
+ require_relative 'stages'
2
3
 
3
4
  module Aireview
4
- # Лимиты контекста в символах: точного токенизатора для провайдеров локально
5
- # нет, а окно Ollama задаётся на сервере и клиенту не видно. Дефолты щедрые,
6
- # под конкретную модель их задают в .aireview.yml.
5
+ # Context limits in characters: there is no exact local tokenizer for the
6
+ # providers, and the Ollama window is set on the server, invisible to the
7
+ # client. The defaults are generous; a specific model gets its own in
8
+ # .aireview.yml.
7
9
  module ConfigLimits
8
- LLM_STAGES = %w[generate critique].freeze
9
10
  DEFAULT_MAX_PROMPT_CHARS = 400_000
10
11
  CONTEXT_DEFAULTS = {
11
12
  'max_diff_chars' => 120_000,
@@ -13,41 +14,15 @@ module Aireview
13
14
  'max_jira_description_chars' => 8_000,
14
15
  'max_jira_comment_chars' => 2_000
15
16
  }.freeze
16
- CONTEXT_ENV = {
17
- 'max_diff_chars' => 'MAX_DIFF_CHARS',
18
- 'max_mr_description_chars' => 'MAX_MR_DESCRIPTION_CHARS',
19
- 'max_jira_description_chars' => 'MAX_JIRA_DESCRIPTION_CHARS',
20
- 'max_jira_comment_chars' => 'MAX_JIRA_COMMENT_CHARS'
21
- }.freeze
22
-
23
- module ClassMethods
24
- def context_env_config(env)
25
- context = CONTEXT_ENV.each_with_object({}) do |(key, env_key), config|
26
- value = parse_integer(env[env_key], env_key)
27
- config[key] = value unless value.nil?
28
- end
29
- context.empty? ? {} : {'context' => context}
30
- end
31
-
32
- # Лимит, который не разобрался, нельзя молча заменять дефолтом: запрос
33
- # уйдёт в модель с окном, которого у неё нет.
34
- def parse_integer(value, name)
35
- return nil if Aireview::Utils.blank?(value)
36
-
37
- Integer(value.to_s, 10)
38
- rescue ArgumentError
39
- raise ConfigError, "#{name} must be an integer, got #{value.inspect}"
40
- end
41
- end
42
-
43
- # Лимит всего запроса стадии в символах: системный промпт плюс контекст
44
- # (для критика ещё и кандидаты). Наследуется из llm как model/temperature.
17
+ # The limit of the whole stage request in characters: the system prompt
18
+ # plus the context (plus the candidates for Critique). Inherited from llm
19
+ # like model/temperature.
45
20
  def max_prompt_chars(stage)
46
21
  stage = stage.to_s
47
- raise ArgumentError, "unknown LLM stage #{stage.inspect}" unless LLM_STAGES.include?(stage)
22
+ raise ArgumentError, "unknown LLM stage #{stage.inspect}" unless STAGES.include?(stage)
48
23
 
49
24
  positive_integer!(
50
- dig('llm', stage, 'max_prompt_chars') || dig('llm', 'max_prompt_chars') || DEFAULT_MAX_PROMPT_CHARS,
25
+ stage_setting(stage, 'max_prompt_chars') || DEFAULT_MAX_PROMPT_CHARS,
51
26
  "llm.#{stage}.max_prompt_chars"
52
27
  )
53
28
  end
@@ -0,0 +1,240 @@
1
+ # frozen_string_literal: true
2
+ require_relative 'stages'
3
+ require 'logger'
4
+ require 'pathname'
5
+ require 'yaml'
6
+ require_relative 'errors'
7
+ require_relative 'utils'
8
+ require_relative 'model_candidate'
9
+ require_relative 'config_layers'
10
+ require_relative 'config_limits'
11
+ require_relative 'config'
12
+
13
+ module Aireview
14
+ # Builds a Config from layers: built-in values, image defaults
15
+ # (AIREVIEW_DEFAULTS), the project's .aireview.yml (searched upwards from
16
+ # cwd), env. Everything that knows environment variable names and file
17
+ # formats lives here; Config only answers questions about merged data.
18
+ module ConfigLoader
19
+ ENV_MAPPING = {
20
+ 'gitlab_url' => 'GITLAB_URL',
21
+ 'gitlab_token' => 'GITLAB_TOKEN',
22
+ 'jira_url' => 'JIRA_URL',
23
+ 'jira_login' => 'JIRA_LOGIN',
24
+ 'jira_password' => 'JIRA_PASSWORD',
25
+ 'review_language' => 'REVIEW_LANGUAGE',
26
+ 'review_mode' => 'REVIEW_MODE',
27
+ 'llm_api_base' => 'LLM_API_BASE',
28
+ 'ollama_api_base' => 'OLLAMA_API_BASE',
29
+ 'llm_http_proxy' => 'LLM_HTTP_PROXY'
30
+ }.freeze
31
+ PROVIDER_KEY_MAPPING = {'gemini' => 'GEMINI_API_KEY'}.freeze
32
+ PROVIDER_KEYS_MAPPING = {'gemini' => 'GEMINI_API_KEYS'}.freeze
33
+ CONTEXT_ENV = {
34
+ 'max_diff_chars' => 'MAX_DIFF_CHARS',
35
+ 'max_mr_description_chars' => 'MAX_MR_DESCRIPTION_CHARS',
36
+ 'max_jira_description_chars' => 'MAX_JIRA_DESCRIPTION_CHARS',
37
+ 'max_jira_comment_chars' => 'MAX_JIRA_COMMENT_CHARS'
38
+ }.freeze
39
+ LLM_ENV = %w[
40
+ LLM_PROVIDER LLM_TEMPERATURE LLM_TIMEOUT LLM_MAX_PROMPT_CHARS LLM_TIME_BUDGET LLM_OVERLOADED_QUARANTINE
41
+ LLM_MODELS LLM_CRITIQUE_RANK LLM_CRITIQUE_ALLOW_WEAKER
42
+ ].freeze
43
+ LLM_STAGE_ENV_SUFFIXES = %w[PROVIDER MODEL TEMPERATURE MAX_PROMPT_CHARS FALLBACK_MODEL START].freeze
44
+ IMAGE_DEFAULTS_ENV = 'AIREVIEW_DEFAULTS'
45
+
46
+ module_function
47
+
48
+ def load(config_path: nil, cwd: Dir.pwd, env: ENV, logger: Logger.new($stderr))
49
+ load_dotenv(cwd)
50
+
51
+ file_path = config_path ? File.expand_path(config_path, cwd) : discover_file(cwd, '.aireview.yml')
52
+ layers = [
53
+ ConfigLayers::Layer.new(name: ConfigLayers::BUILT_IN_LAYER, data: Config::DEFAULTS),
54
+ image_defaults_layer(env),
55
+ file_layer(file_path),
56
+ ConfigLayers::Layer.new(name: ConfigLayers::ENV_LAYER, data: env_config(env))
57
+ ].compact
58
+
59
+ Config.new(config_path: File.file?(file_path) ? file_path : nil, logger: logger, layers: layers)
60
+ end
61
+
62
+ # Every environment variable the loader reads. The CI template passes
63
+ # them into the container by name: a project variable missing from this
64
+ # list never reaches the review.
65
+ def env_names
66
+ stage_env = STAGES.flat_map do |stage|
67
+ LLM_STAGE_ENV_SUFFIXES.map { |suffix| "LLM_#{stage.upcase}_#{suffix}" }
68
+ end
69
+ [
70
+ *ENV_MAPPING.values, *PROVIDER_KEY_MAPPING.values, *PROVIDER_KEYS_MAPPING.values, 'LLM_API_KEY',
71
+ *LLM_ENV, *stage_env, *CONTEXT_ENV.values
72
+ ].uniq
73
+ end
74
+
75
+ def load_dotenv(cwd)
76
+ require 'dotenv'
77
+ dotenv_path = discover_file(cwd, '.env')
78
+ Dotenv.load(dotenv_path) if File.file?(dotenv_path)
79
+ rescue LoadError
80
+ nil
81
+ end
82
+
83
+ def discover_file(cwd, basename)
84
+ current = Pathname.new(cwd).expand_path
85
+
86
+ loop do
87
+ candidate = current.join(basename)
88
+ return candidate.to_s if candidate.file?
89
+
90
+ break if current.root?
91
+
92
+ current = current.parent
93
+ end
94
+
95
+ File.join(cwd, basename)
96
+ end
97
+
98
+ # Image defaults: the Dockerfile sets the path through AIREVIEW_DEFAULTS.
99
+ # A path that is set but missing means a broken image; better to learn
100
+ # that at once.
101
+ def image_defaults_layer(env)
102
+ path = env[IMAGE_DEFAULTS_ENV]
103
+ return nil if Aireview::Utils.blank?(path)
104
+ raise ConfigError, "#{IMAGE_DEFAULTS_ENV} points to a missing file: #{path}" unless File.file?(path)
105
+
106
+ ConfigLayers::Layer.new(name: ConfigLayers::IMAGE_LAYER, path: path, data: read_yaml(path))
107
+ end
108
+
109
+ def file_layer(file_path)
110
+ return nil unless File.file?(file_path)
111
+
112
+ ConfigLayers::Layer.new(name: ConfigLayers::FILE_LAYER, path: file_path, data: read_yaml(file_path))
113
+ end
114
+
115
+ def read_yaml(path)
116
+ Aireview::Utils.normalize_hash(YAML.load_file(path) || {})
117
+ end
118
+
119
+ def env_config(env)
120
+ mapped_env_config(env)
121
+ .merge('llm' => Aireview::Utils.deep_merge(llm_env_config(env), pool_env_config(env)))
122
+ .merge(context_env_config(env))
123
+ .merge(provider_key_env_config(env))
124
+ .merge(provider_keys_env_config(env))
125
+ .merge(generic_api_key_env_config(env))
126
+ end
127
+
128
+ def mapped_env_config(env)
129
+ ENV_MAPPING.each_with_object({}) do |(key, env_key), config|
130
+ value = env[env_key]
131
+ config[key] = value unless Aireview::Utils.blank?(value)
132
+ end
133
+ end
134
+
135
+ def llm_env_config(env)
136
+ {
137
+ 'provider' => env['LLM_PROVIDER'],
138
+ 'temperature' => parse_float(env['LLM_TEMPERATURE']),
139
+ 'timeout' => parse_float(env['LLM_TIMEOUT']),
140
+ 'max_prompt_chars' => parse_integer(env['LLM_MAX_PROMPT_CHARS'], 'LLM_MAX_PROMPT_CHARS'),
141
+ 'time_budget' => parse_integer(env['LLM_TIME_BUDGET'], 'LLM_TIME_BUDGET'),
142
+ 'overloaded_quarantine' => parse_integer(env['LLM_OVERLOADED_QUARANTINE'], 'LLM_OVERLOADED_QUARANTINE'),
143
+ 'generate' => llm_stage_env_config(env, 'GENERATE'),
144
+ 'critique' => llm_stage_env_config(env, 'CRITIQUE')
145
+ }.compact.reject { |key, value| %w[generate critique].include?(key) && value.empty? }
146
+ end
147
+
148
+ def llm_stage_env_config(env, stage)
149
+ {
150
+ 'provider' => env["LLM_#{stage}_PROVIDER"],
151
+ 'model' => env["LLM_#{stage}_MODEL"],
152
+ 'temperature' => parse_float(env["LLM_#{stage}_TEMPERATURE"]),
153
+ 'max_prompt_chars' => parse_integer(env["LLM_#{stage}_MAX_PROMPT_CHARS"], "LLM_#{stage}_MAX_PROMPT_CHARS"),
154
+ 'fallbacks' => fallback_models_env_config(env, stage)
155
+ }.compact
156
+ end
157
+
158
+ # LLM_GENERATE_FALLBACK_MODEL=gemini-3.8-flash (or a comma-separated
159
+ # list) — the provider is separated by a slash because Ollama tags contain a colon.
160
+ def fallback_models_env_config(env, stage)
161
+ value = env["LLM_#{stage}_FALLBACK_MODEL"]
162
+ return nil if Aireview::Utils.blank?(value)
163
+
164
+ value.split(',').map(&:strip).reject(&:empty?).map { |item| ModelCandidate.parse_item(item) }
165
+ end
166
+
167
+ # LLM_MODELS=gemini/gemini-3.8-flash,gemini/gemini-3.7-flash;
168
+ # LLM_GENERATE_START / LLM_CRITIQUE_START — a model from the pool;
169
+ # LLM_CRITIQUE_RANK, LLM_CRITIQUE_ALLOW_WEAKER=true|false.
170
+ def pool_env_config(env)
171
+ models = env['LLM_MODELS'].to_s.split(',').map(&:strip).reject(&:empty?).map do |item|
172
+ ModelCandidate.parse_item(item)
173
+ end
174
+ {
175
+ 'models' => models.empty? ? nil : models,
176
+ 'generate' => {'start' => env['LLM_GENERATE_START']}.compact,
177
+ 'critique' => {
178
+ 'start' => env['LLM_CRITIQUE_START'],
179
+ 'rank' => env['LLM_CRITIQUE_RANK'],
180
+ 'allow_weaker' => parse_boolean(env['LLM_CRITIQUE_ALLOW_WEAKER'], 'LLM_CRITIQUE_ALLOW_WEAKER')
181
+ }.compact
182
+ }.compact.reject { |_, value| value.is_a?(Hash) && value.empty? }
183
+ end
184
+
185
+ def context_env_config(env)
186
+ context = CONTEXT_ENV.each_with_object({}) do |(key, env_key), config|
187
+ value = parse_integer(env[env_key], env_key)
188
+ config[key] = value unless value.nil?
189
+ end
190
+ context.empty? ? {} : {'context' => context}
191
+ end
192
+
193
+ def provider_key_env_config(env)
194
+ PROVIDER_KEY_MAPPING.each_with_object({}) do |(provider, env_key), config|
195
+ value = env[env_key]
196
+ config["#{provider}_api_key"] = value unless Aireview::Utils.blank?(value)
197
+ end
198
+ end
199
+
200
+ def provider_keys_env_config(env)
201
+ PROVIDER_KEYS_MAPPING.each_with_object({}) do |(provider, env_key), config|
202
+ keys = env[env_key].to_s.split(',').map(&:strip).reject(&:empty?)
203
+ config["#{provider}_api_keys"] = keys unless keys.empty?
204
+ end
205
+ end
206
+
207
+ def generic_api_key_env_config(env)
208
+ api_key = env['LLM_API_KEY']
209
+ return {} if Aireview::Utils.blank?(api_key)
210
+
211
+ {'llm_api_key' => api_key}
212
+ end
213
+
214
+ def parse_float(value)
215
+ return nil if Aireview::Utils.blank?(value)
216
+
217
+ Float(value)
218
+ rescue ArgumentError
219
+ nil
220
+ end
221
+
222
+ # A limit that failed to parse must not silently fall back to the
223
+ # default: the request would go to a model with a window it does not have.
224
+ def parse_integer(value, name)
225
+ return nil if Aireview::Utils.blank?(value)
226
+
227
+ Integer(value.to_s, 10)
228
+ rescue ArgumentError
229
+ raise ConfigError, "#{name} must be an integer, got #{value.inspect}"
230
+ end
231
+
232
+ def parse_boolean(value, name)
233
+ return nil if Aireview::Utils.blank?(value)
234
+ return true if %w[true 1 yes].include?(value.to_s.downcase)
235
+ return false if %w[false 0 no].include?(value.to_s.downcase)
236
+
237
+ raise ConfigError, "#{name} must be true or false, got #{value.inspect}"
238
+ end
239
+ end
240
+ end