aireview 0.3.0 → 2.0.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.
data/lib/aireview/cli.rb CHANGED
@@ -1,8 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
  require 'securerandom'
3
+ require_relative 'model_checker'
3
4
 
4
5
  module Aireview
5
- class CLI
6
+ class CLI # rubocop:disable Metrics/ClassLength
6
7
  def self.start(argv, out: $stdout, err: $stderr, env: ENV)
7
8
  new(argv, out: out, err: err, env: env).start
8
9
  end
@@ -24,6 +25,8 @@ module Aireview
24
25
  case command
25
26
  when 'review'
26
27
  run_review(@argv)
28
+ when 'models'
29
+ run_models(@argv)
27
30
  when '--help', '-h', nil
28
31
  @out.puts(help)
29
32
  0
@@ -41,6 +44,33 @@ module Aireview
41
44
 
42
45
  private
43
46
 
47
+ # aireview models check [--config PATH] [--verbose]: a probe request with
48
+ # the production schemas to every model of the chains; see ModelChecker.
49
+ def run_models(argv)
50
+ options = parse_models_options(argv)
51
+ raise ParseError, "Usage: aireview models check [options] (got: #{argv.join(' ')})" unless argv == ['check']
52
+
53
+ @logger.level = Logger::DEBUG if options[:verbose]
54
+ config = Config.load(config_path: options[:config], cwd: Dir.pwd, env: @env, logger: @logger)
55
+ config.warnings.each { |warning| @logger.warn(warning) }
56
+ ModelChecker.new(config: config, out: @out, logger: @logger, strict: options[:strict] == true).run
57
+ end
58
+
59
+ def parse_models_options(argv)
60
+ options = {}
61
+ OptionParser.new do |parser|
62
+ parser.banner = 'Usage: aireview models check [options]'
63
+ parser.on('--config PATH', 'Path to .aireview.yml') { |value| options[:config] = value }
64
+ parser.on('--strict', 'Treat a skipped model (unreachable Ollama) as a failure') { options[:strict] = true }
65
+ parser.on('--verbose', 'Enable debug logging') { options[:verbose] = true }
66
+ parser.on('-h', '--help', 'Show help') do
67
+ @out.puts(parser)
68
+ raise Aireview::HelpRequested
69
+ end
70
+ end.parse!(argv)
71
+ options
72
+ end
73
+
44
74
  def run_review(argv)
45
75
  options, mr_url = review_options_and_url(argv)
46
76
  parser_result = MrParser.parse(mr_url)
@@ -72,6 +102,7 @@ module Aireview
72
102
  no_fallbacks: options[:no_fallbacks] == true
73
103
  )
74
104
  config.require_llm_configuration!
105
+ config.warnings.each { |warning| @logger.warn(warning) }
75
106
  config
76
107
  end
77
108
 
@@ -103,8 +134,8 @@ module Aireview
103
134
  [merge_request, changes]
104
135
  end
105
136
 
106
- # Дифф уходит дальше по файлам, а не одной строкой: бюджет контекста
107
- # режет его по границам файлов и хунков.
137
+ # The diff travels on as files, not as one string: the context budget
138
+ # cuts it at file and hunk boundaries.
108
139
  def prepare_changes(changes, config)
109
140
  diff_fetcher = DiffFetcher.new(ignore_paths: config.ignore_paths, logger: @logger)
110
141
  filtered_changes = diff_fetcher.filter(changes)
@@ -141,8 +172,8 @@ module Aireview
141
172
  critique: !options[:no_critique]
142
173
  )
143
174
 
144
- # Печатаем до публикации: если публикация не состоится, текст ревью
145
- # останется хотя бы в логе джоба.
175
+ # Printed before publishing: if publishing fails, the review text at
176
+ # least stays in the job log.
146
177
  @out.puts(review)
147
178
 
148
179
  publish_review(review, context, publication) if publication
@@ -150,8 +181,8 @@ module Aireview
150
181
  0
151
182
  end
152
183
 
153
- # Поиск прошлого ревью идёт до вызова LLM: иначе запросы тратятся впустую,
154
- # даже когда публиковать нечего.
184
+ # The previous review is looked up before calling the LLM: otherwise the
185
+ # requests are wasted even when there is nothing to publish.
155
186
  def prepare_publication(pipeline, config, context, options)
156
187
  return nil unless options[:post]
157
188
 
@@ -205,10 +236,10 @@ module Aireview
205
236
  )
206
237
  end
207
238
 
208
- # Пока работала LLM, MR мог уехать: новый коммит, перебазирование или смена
209
- # целевой ветки. Публиковать ревью неактуального диффа хуже, чем не
210
- # публиковать ничего, а ошибку проверки нельзя трактовать как «всё на
211
- # месте», поэтому её не глушим.
239
+ # While the LLM was working the MR may have moved on: a new commit, a
240
+ # rebase or a target branch change. Publishing a review of a stale diff
241
+ # is worse than publishing nothing, and a failed check cannot be read as
242
+ # "all in place", so it is not swallowed.
212
243
  def merge_request_moved?(context)
213
244
  current = context[:gitlab_client].fetch_merge_request(
214
245
  context[:parser_result].project_id,
@@ -333,9 +364,12 @@ module Aireview
333
364
  <<~HELP
334
365
  Usage:
335
366
  aireview review <merge_request_url> [options]
367
+ aireview models check [--config PATH] [--strict] [--verbose]
336
368
 
337
369
  Commands:
338
- review Run review for a GitLab merge request URL
370
+ review Run review for a GitLab merge request URL
371
+ models check Send a probe request with the generate and critique schemas
372
+ to every model of both stages; exit 1 if any fails
339
373
 
340
374
  Options:
341
375
  --post Post review as a merge request note
@@ -6,13 +6,17 @@ require_relative 'errors'
6
6
  require_relative 'utils'
7
7
  require_relative 'config_limits'
8
8
  require_relative 'config_fallbacks'
9
+ require_relative 'config_layers'
10
+ require_relative 'config_loader'
9
11
 
10
12
  module Aireview
13
+ # Answers questions about the merged settings: values, their source
14
+ # (layer), the routing plan, provider keys. How settings are read from
15
+ # files and the environment is ConfigLoader's business.
11
16
  class Config
12
17
  include ConfigLimits
13
18
  include ConfigFallbacks
14
- extend ConfigLimits::ClassMethods
15
- extend ConfigFallbacks::ClassMethods
19
+ include ConfigLayers
16
20
 
17
21
  DEFAULT_SECRET_FILES = [
18
22
  '.env',
@@ -43,159 +47,35 @@ module Aireview
43
47
  'temperature' => 0,
44
48
  'timeout' => 60,
45
49
  'max_prompt_chars' => ConfigLimits::DEFAULT_MAX_PROMPT_CHARS,
46
- 'time_budget' => ConfigFallbacks::DEFAULT_TIME_BUDGET
50
+ 'time_budget' => ConfigFallbacks::DEFAULT_TIME_BUDGET,
51
+ 'overloaded_quarantine' => ConfigFallbacks::DEFAULT_OVERLOADED_QUARANTINE
47
52
  },
48
53
  'context' => ConfigLimits::CONTEXT_DEFAULTS
49
54
  }.freeze
50
55
 
51
- ENV_MAPPING = {
52
- 'gitlab_url' => 'GITLAB_URL',
53
- 'gitlab_token' => 'GITLAB_TOKEN',
54
- 'jira_url' => 'JIRA_URL',
55
- 'jira_login' => 'JIRA_LOGIN',
56
- 'jira_password' => 'JIRA_PASSWORD',
57
- 'review_language' => 'REVIEW_LANGUAGE',
58
- 'review_mode' => 'REVIEW_MODE',
59
- 'llm_api_base' => 'LLM_API_BASE',
60
- 'ollama_api_base' => 'OLLAMA_API_BASE',
61
- 'llm_http_proxy' => 'LLM_HTTP_PROXY'
62
- }.freeze
63
-
64
- PROVIDER_KEY_MAPPING = {
65
- 'gemini' => 'GEMINI_API_KEY',
66
- 'ollama' => nil
67
- }.freeze
68
-
69
- attr_reader :config_path
70
-
71
- def self.load(config_path: nil, cwd: Dir.pwd, env: ENV, logger: Logger.new($stderr))
72
- load_dotenv(cwd)
73
-
74
- file_path = config_path ? File.expand_path(config_path, cwd) : discover_file(cwd, '.aireview.yml')
75
- file_config = File.file?(file_path) ? normalize_hash(YAML.load_file(file_path) || {}) : {}
76
-
77
- merged = deep_merge(DEFAULTS, file_config)
78
- merged = deep_merge(merged, env_config(env))
79
-
80
- new(merged, config_path: File.file?(file_path) ? file_path : nil, logger: logger)
81
- end
82
-
83
- def self.load_dotenv(cwd)
84
- require 'dotenv'
85
- dotenv_path = discover_file(cwd, '.env')
86
- Dotenv.load(dotenv_path) if File.file?(dotenv_path)
87
- rescue LoadError
88
- nil
89
- end
90
-
91
- def self.discover_file(cwd, basename)
92
- current = Pathname.new(cwd).expand_path
93
-
94
- loop do
95
- candidate = current.join(basename)
96
- return candidate.to_s if candidate.file?
97
-
98
- break if current.root?
99
-
100
- current = current.parent
101
- end
102
-
103
- File.join(cwd, basename)
104
- end
105
-
106
- def self.env_config(env)
107
- mapped_env_config(env)
108
- .merge('llm' => llm_env_config(env))
109
- .merge(context_env_config(env))
110
- .merge(provider_key_env_config(env))
111
- .merge(provider_keys_env_config(env))
112
- .merge(generic_api_key_env_config(env))
113
- end
114
-
115
- def self.mapped_env_config(env)
116
- ENV_MAPPING.each_with_object({}) do |(key, env_key), config|
117
- value = env[env_key]
118
- config[key] = value unless Aireview::Utils.blank?(value)
119
- end
120
- end
121
-
122
- def self.llm_env_config(env)
123
- {
124
- 'provider' => env['LLM_PROVIDER'],
125
- 'temperature' => parse_float(env['LLM_TEMPERATURE']),
126
- 'timeout' => parse_float(env['LLM_TIMEOUT']),
127
- 'max_prompt_chars' => parse_integer(env['LLM_MAX_PROMPT_CHARS'], 'LLM_MAX_PROMPT_CHARS'),
128
- 'time_budget' => parse_integer(env['LLM_TIME_BUDGET'], 'LLM_TIME_BUDGET'),
129
- 'generate' => llm_stage_env_config(env, 'GENERATE'),
130
- 'critique' => llm_stage_env_config(env, 'CRITIQUE')
131
- }.compact.reject { |key, value| %w[generate critique].include?(key) && value.empty? }
132
- end
133
-
134
- def self.llm_stage_env_config(env, stage)
135
- {
136
- 'provider' => env["LLM_#{stage}_PROVIDER"],
137
- 'model' => env["LLM_#{stage}_MODEL"],
138
- 'temperature' => parse_float(env["LLM_#{stage}_TEMPERATURE"]),
139
- 'max_prompt_chars' => parse_integer(env["LLM_#{stage}_MAX_PROMPT_CHARS"], "LLM_#{stage}_MAX_PROMPT_CHARS"),
140
- 'fallbacks' => fallback_models_env_config(env, stage)
141
- }.compact
142
- end
56
+ attr_reader :config_path, :layers
143
57
 
144
- def self.provider_key_env_config(env)
145
- PROVIDER_KEY_MAPPING.each_with_object({}) do |(provider, env_key), config|
146
- next unless env_key
147
-
148
- value = env[env_key]
149
- config["#{provider}_api_key"] = value unless Aireview::Utils.blank?(value)
150
- end
151
- end
152
-
153
- def self.generic_api_key_env_config(env)
154
- api_key = env['LLM_API_KEY']
155
- return {} if Aireview::Utils.blank?(api_key)
156
-
157
- {'llm_api_key' => api_key}
58
+ def self.load(**options)
59
+ ConfigLoader.load(**options)
158
60
  end
159
61
 
160
- def self.parse_float(value)
161
- return nil if Aireview::Utils.blank?(value)
162
-
163
- Float(value)
164
- rescue ArgumentError
165
- nil
62
+ def self.env_names
63
+ ConfigLoader.env_names
166
64
  end
167
65
 
168
- def self.deep_merge(left, right)
169
- left.merge(right) do |_, old_value, new_value|
170
- if old_value.is_a?(Hash) && new_value.is_a?(Hash)
171
- deep_merge(old_value, new_value)
172
- else
173
- new_value
174
- end
175
- end
176
- end
177
-
178
- def self.normalize_hash(value)
179
- case value
180
- when Hash
181
- value.each_with_object({}) do |(key, inner_value), result|
182
- result[key.to_s] = normalize_hash(inner_value)
183
- end
184
- when Array
185
- value.map { |item| normalize_hash(item) }
186
- else
187
- value
188
- end
189
- end
190
-
191
- def initialize(data, config_path:, logger:)
192
- @data = self.class.normalize_hash(data)
66
+ # The layers are the single source of truth: the merged data is computed
67
+ # from them. A config built from a hash (without ConfigLoader) is one
68
+ # layer, otherwise the CLI layer from with_overrides would be the only
69
+ # one and the stage settings of the original hash would be lost.
70
+ def initialize(data = nil, config_path: nil, logger: Logger.new($stderr), layers: nil)
71
+ @layers = layers || [ConfigLayers::Layer.new(name: ConfigLayers::DATA_LAYER, data: Utils.normalize_hash(data))]
72
+ @data = @layers.map(&:data).reduce({}) { |merged, layer_data| Utils.deep_merge(merged, layer_data) }
193
73
  @config_path = config_path
194
74
  @logger = logger
195
75
  end
196
76
 
197
- # Переопределения из CLI меняют только основную модель стадии, запасные
198
- # из конфига остаются; no_fallbacks оставляет одну модель и один ключ.
77
+ # CLI overrides change only the primary model of a stage, the reserves
78
+ # from the config stay; no_fallbacks leaves one model and one key.
199
79
  def with_overrides(
200
80
  generate_model: nil,
201
81
  critique_model: nil,
@@ -212,7 +92,11 @@ module Aireview
212
92
  overrides['fallbacks_disabled'] = true if no_fallbacks
213
93
  return self if overrides.empty?
214
94
 
215
- self.class.new(self.class.deep_merge(@data, overrides), config_path: config_path, logger: @logger)
95
+ self.class.new(
96
+ config_path: config_path,
97
+ logger: @logger,
98
+ layers: @layers + [ConfigLayers::Layer.new(name: ConfigLayers::CLI_LAYER, data: overrides)]
99
+ )
216
100
  end
217
101
 
218
102
  def gitlab_url
@@ -247,28 +131,42 @@ module Aireview
247
131
  dig('llm', 'timeout') || DEFAULTS.dig('llm', 'timeout')
248
132
  end
249
133
 
134
+ # The primary model of a stage is the first in its chain: the start for
135
+ # Generate, the first of the pool for Critique. These go into the review key.
250
136
  def generate_model
251
- dig('llm', 'generate', 'model')
137
+ routing.primary('generate').model
252
138
  end
253
139
 
254
140
  def critique_model
255
- dig('llm', 'critique', 'model')
141
+ routing.primary('critique').model
256
142
  end
257
143
 
258
144
  def generate_provider
259
- dig('llm', 'generate', 'provider') || llm_provider
145
+ routing.primary('generate').provider
146
+ end
147
+
148
+ # Everything besides the prompt that affects the review result goes into
149
+ # the note key (see ReviewMarker): provider, model and temperature of the
150
+ # stages, the shared pool with its critique policy. Reserves of per-stage
151
+ # chains do not change the result.
152
+ def result_signature
153
+ {
154
+ 'generate' => [generate_provider, generate_model, generate_temperature],
155
+ 'critique' => [critique_provider, critique_model, critique_temperature],
156
+ 'pool' => routing.signature
157
+ }
260
158
  end
261
159
 
262
160
  def critique_provider
263
- dig('llm', 'critique', 'provider') || llm_provider
161
+ routing.primary('critique').provider
264
162
  end
265
163
 
266
164
  def generate_temperature
267
- dig('llm', 'generate', 'temperature') || llm_temperature
165
+ stage_setting('generate', 'temperature') || DEFAULTS.dig('llm', 'temperature')
268
166
  end
269
167
 
270
168
  def critique_temperature
271
- dig('llm', 'critique', 'temperature') || llm_temperature
169
+ stage_setting('critique', 'temperature') || DEFAULTS.dig('llm', 'temperature')
272
170
  end
273
171
 
274
172
  def llm_api_base
@@ -287,8 +185,8 @@ module Aireview
287
185
  @data['review_language'] || DEFAULTS['review_language']
288
186
  end
289
187
 
290
- # update — обновляем свою заметку, когда дифф или настройки изменились,
291
- # once — ревьюим один раз автоматически; Retry джоба обновляет ревью при изменениях.
188
+ # update — our note is updated when the diff or the settings changed,
189
+ # once — one automatic review; a job Retry updates the review on changes.
292
190
  def review_mode
293
191
  mode = (@data['review_mode'] || DEFAULTS['review_mode']).to_s
294
192
  return mode if REVIEW_MODES.include?(mode)
@@ -333,42 +231,29 @@ module Aireview
333
231
  raise ConfigError, 'GITLAB_TOKEN is required'
334
232
  end
335
233
 
336
- def require_models!
337
- missing = []
338
- missing << 'llm.generate.model (or LLM_GENERATE_MODEL)' if Aireview::Utils.blank?(generate_model)
339
- missing << 'llm.critique.model (or LLM_CRITIQUE_MODEL)' if Aireview::Utils.blank?(critique_model)
340
- raise ConfigError, "LLM models are required: #{missing.join(', ')}" unless missing.empty?
341
- end
342
-
343
- def require_llm_configuration!
344
- require_models!
345
-
346
- missing_keys = ConfigLimits::LLM_STAGES.flat_map do |stage|
347
- providers = stage_chain(stage).map(&:provider).uniq.reject { |provider| provider_keys_present?(provider) }
348
- providers.map { |provider| "#{stage}: API key is required for provider #{provider.inspect}" }
349
- end
350
- raise ConfigError, missing_keys.join(', ') unless missing_keys.empty?
351
- end
352
-
353
234
  private
354
235
 
355
- def provider_keys_present?(provider)
356
- return true if ConfigFallbacks::KEYLESS_PROVIDERS.include?(provider.to_s)
236
+ # Without a pool an override changes only the primary model of the stage,
237
+ # the reserves stay. With a pool the stage mode switches explicitly: a
238
+ # model from the pool becomes the start and the stage's own model and
239
+ # fallbacks are reset; a model outside the pool is a single chain, start
240
+ # and fallbacks are reset.
241
+ def stage_overrides(model:, temperature:)
242
+ overrides = {'temperature' => temperature}.compact
243
+ return overrides unless model
357
244
 
358
- provider_api_keys(provider).any? { |key| Aireview::Utils.present?(key) }
359
- end
245
+ items = Array(dig('llm', 'models'))
246
+ return overrides.merge('model' => model) if items.empty?
360
247
 
361
- def stage_overrides(model:, temperature:)
362
- {
363
- 'model' => model,
364
- 'temperature' => temperature
365
- }.compact
248
+ if ModelPool.member?(items, llm_provider, model)
249
+ overrides.merge('start' => model, 'model' => nil, 'fallbacks' => nil)
250
+ else
251
+ overrides.merge('start' => nil, 'model' => model, 'fallbacks' => [])
252
+ end
366
253
  end
367
254
 
368
255
  def dig(*keys)
369
- keys.reduce(@data) do |accumulator, key|
370
- accumulator.is_a?(Hash) ? accumulator[key] : nil
371
- end
256
+ Utils.dig(@data, *keys)
372
257
  end
373
258
  end
374
259
  end
@@ -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