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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +46 -0
- data/README.md +277 -28
- data/config/defaults.yml +47 -0
- data/lib/aireview/candidate_checker.rb +24 -22
- data/lib/aireview/cli.rb +46 -12
- data/lib/aireview/config.rb +65 -180
- data/lib/aireview/config_fallbacks.rb +79 -68
- data/lib/aireview/config_layers.rb +110 -0
- data/lib/aireview/config_limits.rb +10 -35
- data/lib/aireview/config_loader.rb +240 -0
- data/lib/aireview/context_budget.rb +22 -20
- data/lib/aireview/context_builder.rb +18 -17
- data/lib/aireview/diff_fetcher.rb +12 -11
- data/lib/aireview/dry_run_report.rb +33 -8
- data/lib/aireview/errors.rb +8 -0
- data/lib/aireview/gitlab_client.rb +10 -9
- data/lib/aireview/llm_client.rb +113 -0
- data/lib/aireview/llm_failure.rb +36 -19
- data/lib/aireview/llm_router.rb +315 -158
- data/lib/aireview/model_candidate.rb +28 -0
- data/lib/aireview/model_checker.rb +148 -0
- data/lib/aireview/model_pool.rb +224 -0
- data/lib/aireview/model_state.rb +82 -0
- data/lib/aireview/publisher.rb +6 -6
- data/lib/aireview/result_parser.rb +98 -0
- data/lib/aireview/review_marker.rb +16 -28
- data/lib/aireview/review_pipeline.rb +88 -86
- data/lib/aireview/review_renderer.rb +19 -11
- data/lib/aireview/reviewer.rb +37 -109
- data/lib/aireview/stage_chains.rb +113 -0
- data/lib/aireview/stages.rb +7 -0
- data/lib/aireview/utils.rb +29 -0
- data/lib/aireview/version.rb +1 -1
- data/lib/aireview.rb +1 -0
- metadata +19 -3
- data/lib/aireview/result_validation.rb +0 -65
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
|
-
#
|
|
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
|
-
#
|
|
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
|
|
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
|
data/lib/aireview/config.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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.
|
|
145
|
-
|
|
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.
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
Float(value)
|
|
164
|
-
rescue ArgumentError
|
|
165
|
-
nil
|
|
62
|
+
def self.env_names
|
|
63
|
+
ConfigLoader.env_names
|
|
166
64
|
end
|
|
167
65
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
#
|
|
198
|
-
#
|
|
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(
|
|
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
|
-
|
|
137
|
+
routing.primary('generate').model
|
|
252
138
|
end
|
|
253
139
|
|
|
254
140
|
def critique_model
|
|
255
|
-
|
|
141
|
+
routing.primary('critique').model
|
|
256
142
|
end
|
|
257
143
|
|
|
258
144
|
def generate_provider
|
|
259
|
-
|
|
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
|
-
|
|
161
|
+
routing.primary('critique').provider
|
|
264
162
|
end
|
|
265
163
|
|
|
266
164
|
def generate_temperature
|
|
267
|
-
|
|
165
|
+
stage_setting('generate', 'temperature') || DEFAULTS.dig('llm', 'temperature')
|
|
268
166
|
end
|
|
269
167
|
|
|
270
168
|
def critique_temperature
|
|
271
|
-
|
|
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 —
|
|
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
|
-
|
|
356
|
-
|
|
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
|
-
|
|
359
|
-
|
|
245
|
+
items = Array(dig('llm', 'models'))
|
|
246
|
+
return overrides.merge('model' => model) if items.empty?
|
|
360
247
|
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
'
|
|
365
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
89
|
-
#
|
|
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
|
-
|
|
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
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|