aireview 0.2.1 → 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.
@@ -1,295 +1,70 @@
1
1
  # frozen_string_literal: true
2
2
  require_relative 'errors'
3
3
  require_relative 'output_schemas'
4
- require_relative 'utils'
5
- require 'timeout'
4
+ require_relative 'llm_router'
5
+ require_relative 'llm_client'
6
6
 
7
7
  module Aireview
8
+ # The review stages on top of the router: Generate and Critique with their
9
+ # own schemas and temperatures. LlmClient makes the request, LlmRouter
10
+ # walks the models.
8
11
  class Reviewer
9
- MAX_TRANSIENT_RETRIES = 3
10
- TRANSIENT_RETRY_BASE_DELAY = 2.0
11
- TRANSIENT_RETRY_JITTER_RANGE = 2.0..5.0
12
- PROVIDER_RETRY_DELAY_MULTIPLIER_RANGE = 2.0..2.4
13
- OVERLOADED_RETRY_DELAYS = [120.0, 300.0, 300.0, 300.0].freeze
14
- OVERLOADED_RETRY_JITTER_RANGE = 0.85..1.15
15
- RETRY_WAIT_LOG_FORMAT = 'LLM %<stage>s request will sleep %<delay>.1fs before retry%<source>s ' \
16
- '(attempt %<next_attempt>d/%<max_attempts>d, model=%<model>s)'
12
+ attr_reader :router
17
13
 
18
- def initialize(config:, logger: Logger.new($stderr))
14
+ def initialize(config:, logger: Logger.new($stderr), router: nil, client: nil)
19
15
  @config = config
20
16
  @logger = logger
21
- @llm_contexts = {}
17
+ @router = router || LlmRouter.new(config: config, logger: logger)
18
+ @client = client || LlmClient.new(config: config, logger: logger)
22
19
  end
23
20
 
24
- def generate(system_prompt:, user_prompt:)
25
- call_llm(
26
- stage: 'generate',
27
- system: system_prompt,
28
- user: user_prompt,
29
- options: {
30
- model: @config.generate_model,
31
- temperature: @config.generate_temperature,
32
- provider: @config.generate_provider,
33
- schema: GenerateOutputSchema
34
- }
35
- )
21
+ # pinned — a request only to the model that answered last in the stage
22
+ # (the repair of its own JSON): its failure is RepairImpossibleError.
23
+ def generate(system_prompt:, user_prompt:, pinned: false)
24
+ prompt = LlmClient::Prompt.new(stage: 'generate', system: system_prompt, user: user_prompt,
25
+ temperature: @config.generate_temperature, schema: GenerateOutputSchema)
26
+ call_llm(prompt, pinned: pinned)
36
27
  end
37
28
 
38
- def critique(system_prompt:, user_prompt:)
39
- call_llm(
40
- stage: 'critique',
41
- system: system_prompt,
42
- user: user_prompt,
43
- options: {
44
- model: @config.critique_model,
45
- temperature: @config.critique_temperature,
46
- provider: @config.critique_provider,
47
- schema: CritiqueOutputSchema
48
- }
49
- )
29
+ def critique(system_prompt:, user_prompt:, pinned: false)
30
+ prompt = LlmClient::Prompt.new(stage: 'critique', system: system_prompt, user: user_prompt,
31
+ temperature: @config.critique_temperature, schema: CritiqueOutputSchema)
32
+ call_llm(prompt, pinned: pinned)
50
33
  end
51
34
 
52
- private
53
-
54
- def call_llm(stage:, system:, user:, options:)
55
- require 'ruby_llm'
56
-
57
- @config.require_llm_configuration!
58
- context = llm_context(stage, options[:provider])
59
- response = request_with_retries(stage: stage, model: options[:model]) do
60
- perform_llm_request(
61
- context: context,
62
- stage: stage,
63
- system: system,
64
- user: user,
65
- options: options
66
- )
67
- end
68
- response.content
69
- rescue LoadError => e
70
- @logger.error("LLM #{stage} setup failed: #{e.message}")
71
- raise ConfigError, "Missing dependency: #{e.message}"
72
- rescue Timeout::Error
73
- @logger.warn(
74
- "LLM #{stage} request timed out after #{@config.llm_timeout} seconds (model=#{options[:model]})"
75
- )
76
- raise ApiError, "LLM request timed out after #{@config.llm_timeout} seconds. " \
77
- "Try again later or switch model via --generate-model/--critique-model."
78
- end
79
-
80
- def perform_llm_request(context:, stage:, system:, user:, options:)
81
- model = options[:model]
82
- temperature = options[:temperature]
83
- @logger.info("LLM #{stage} request started (model=#{model}, temperature=#{temperature})")
84
- chat = build_chat(context: context, stage: stage, model: model, provider: options[:provider])
85
- chat = configure_reasoning(chat: chat, model: model, provider: options[:provider])
86
- .with_temperature(temperature.to_f)
87
- .with_schema(options[:schema])
88
- chat.with_instructions(system)
89
- response = Timeout.timeout(@config.llm_timeout.to_f) { chat.ask(user) }
90
- @logger.info("LLM #{stage} request completed (model=#{model})")
91
- response
92
- end
93
-
94
- def configure_reasoning(chat:, model:, provider:)
95
- return chat unless provider == 'ollama' && model.start_with?('gpt-oss:')
96
-
97
- chat.with_thinking(effort: :low)
98
- end
99
-
100
- def build_chat(context:, stage:, model:, provider:)
101
- context.chat(model: model, provider: provider.to_sym)
102
- rescue RubyLLM::ModelNotFoundError
103
- @logger.warn(
104
- "LLM #{stage}: model not found in RubyLLM registry; " \
105
- "using fallback with incomplete model metadata " \
106
- "(model=#{model}, provider=#{provider})"
107
- )
108
- context.chat(model: model, provider: provider.to_sym, assume_model_exists: true)
109
- end
110
-
111
- def request_with_retries(stage:, model:)
112
- attempt = 0
113
-
114
- begin
115
- attempt += 1
116
- yield
117
- rescue StandardError => e
118
- raise unless ruby_llm_api_error?(e)
119
-
120
- @logger.warn("LLM #{stage} request failed (model=#{model}): #{e.class}: #{e.message}")
121
- raise ApiError, llm_error_message(e) unless retry_llm_request?(e, attempt)
122
-
123
- wait_before_retry(error: e, attempt: attempt, stage: stage, model: model)
124
- retry
125
- end
126
- end
127
-
128
- def retry_llm_request?(error, attempt)
129
- transient_llm_error?(error) && attempt <= max_retries(error)
130
- end
131
-
132
- # Перегруженному провайдеру даётся на один повтор больше: 503 у Gemini
133
- # держится дольше, чем rate limit или сетевой сбой.
134
- def max_retries(error)
135
- overloaded_llm_error?(error) ? OVERLOADED_RETRY_DELAYS.size : MAX_TRANSIENT_RETRIES
136
- end
137
-
138
- def wait_before_retry(error:, attempt:, stage:, model:)
139
- retry_delay = transient_retry_delay(error: error, attempt: attempt)
140
- @logger.warn(
141
- format(
142
- RETRY_WAIT_LOG_FORMAT,
143
- stage: stage,
144
- delay: retry_delay[:delay],
145
- source: retry_delay_source(retry_delay),
146
- next_attempt: attempt + 1,
147
- max_attempts: max_retries(error) + 1,
148
- model: model
149
- )
150
- )
151
- sleep_started_at = monotonic_time
152
- sleep(retry_delay[:delay])
153
- log_retry_wait_completed(stage, model, monotonic_time - sleep_started_at)
154
- end
155
-
156
- def log_retry_wait_completed(stage, model, waited)
157
- @logger.info(
158
- format(
159
- 'LLM %<stage>s retry wait completed after %<waited>.1fs (model=%<model>s)',
160
- stage: stage,
161
- waited: waited,
162
- model: model
163
- )
164
- )
165
- end
166
-
167
- def ruby_llm_api_error?(error)
168
- defined?(RubyLLM::Error) && error.is_a?(RubyLLM::Error)
35
+ # Stages answered by a fallback model, for the report line.
36
+ def fallback_models
37
+ @router.fallback_models
169
38
  end
170
39
 
171
- def transient_llm_error?(error)
172
- error.is_a?(RubyLLM::RateLimitError) ||
173
- error.is_a?(RubyLLM::ServiceUnavailableError) ||
174
- error.is_a?(RubyLLM::OverloadedError)
40
+ # The model that answered last in the stage with its place in the chain, for the log.
41
+ def answered_model(stage)
42
+ @router.answered(stage)
175
43
  end
176
44
 
177
- def monotonic_time
178
- Process.clock_gettime(Process::CLOCK_MONOTONIC)
45
+ # Critique ran on a model below Generate in the pool (allow_weaker).
46
+ def critique_weaker?
47
+ @router.critique_weaker?
179
48
  end
180
49
 
181
- def transient_retry_delay(error:, attempt:)
182
- provider_delay = retry_after_seconds(error.message)
183
- if provider_delay
184
- multiplier = rand(PROVIDER_RETRY_DELAY_MULTIPLIER_RANGE)
185
- return {
186
- delay: provider_delay * multiplier,
187
- provider_delay: provider_delay,
188
- multiplier: multiplier,
189
- strategy: :provider_hint
190
- }
191
- end
192
-
193
- if overloaded_llm_error?(error)
194
- base_delay = OVERLOADED_RETRY_DELAYS.fetch(attempt - 1, OVERLOADED_RETRY_DELAYS.last)
195
- multiplier = rand(OVERLOADED_RETRY_JITTER_RANGE)
196
- return {
197
- delay: base_delay * multiplier,
198
- base_delay: base_delay,
199
- multiplier: multiplier,
200
- strategy: :overloaded
201
- }
202
- end
203
-
204
- base_delay = TRANSIENT_RETRY_BASE_DELAY * (2**(attempt - 1))
205
- {
206
- delay: base_delay + rand(TRANSIENT_RETRY_JITTER_RANGE),
207
- provider_delay: nil,
208
- multiplier: nil,
209
- strategy: :fallback
210
- }
211
- end
212
-
213
- def retry_delay_source(retry_delay)
214
- if retry_delay[:strategy] == :overloaded
215
- return format(
216
- ' (overloaded backoff %<base_delay>.0fs, multiplier %<multiplier>.2fx)',
217
- base_delay: retry_delay[:base_delay],
218
- multiplier: retry_delay[:multiplier]
219
- )
220
- end
221
- return '' unless retry_delay[:provider_delay]
222
-
223
- format(
224
- ' (provider retry hint %<provider_delay>.1fs, multiplier %<multiplier>.2fx)',
225
- provider_delay: retry_delay[:provider_delay],
226
- multiplier: retry_delay[:multiplier]
227
- )
228
- end
229
-
230
- def overloaded_llm_error?(error)
231
- error.is_a?(RubyLLM::ServiceUnavailableError) ||
232
- error.is_a?(RubyLLM::OverloadedError)
233
- end
234
-
235
- def retry_after_seconds(message)
236
- match = message.to_s.match(/retry\s+(?:in|after)\s+(\d+(?:\.\d+)?)\s*(?:s|sec|secs|second|seconds)\b/i)
237
- match[1].to_f if match
238
- end
239
-
240
- def llm_error_message(error)
241
- case error
242
- when RubyLLM::ServiceUnavailableError, RubyLLM::OverloadedError
243
- "LLM service is temporarily unavailable or overloaded: #{error.message}. " \
244
- "Try again later or switch model via --generate-model/--critique-model."
245
- when RubyLLM::RateLimitError
246
- "LLM rate limit exceeded: #{error.message}. " \
247
- "Try again later or switch model via --generate-model/--critique-model."
248
- when RubyLLM::ContextLengthExceededError
249
- "LLM context limit exceeded: #{error.message}. Try reducing the MR diff or ignore more paths."
250
- else
251
- "LLM API request failed: #{error.message}"
252
- end
253
- end
254
-
255
- def llm_context(stage, provider)
256
- @llm_contexts[stage] ||= build_llm_context(provider.to_s)
257
- end
258
-
259
- def build_llm_context(provider)
260
- api_key = @config.provider_api_key(provider) unless provider == 'ollama'
261
-
262
- RubyLLM.context do |ruby_config|
263
- configure_http_proxy(ruby_config)
264
- ruby_config.request_timeout = @config.llm_timeout.to_f
265
- configure_provider(ruby_config, provider, api_key)
266
- end
50
+ # An invalid result: the model is excluded for the stage, the next
51
+ # request of the stage goes to another. Returns the excluded model or nil.
52
+ def exclude_answered_model(stage:, reason:)
53
+ @router.exclude_answered(stage: stage, reason: reason)
267
54
  end
268
55
 
269
- def configure_http_proxy(ruby_config)
270
- return unless Aireview::Utils.present?(@config.llm_http_proxy)
271
-
272
- ruby_config.http_proxy = @config.llm_http_proxy
273
- end
56
+ private
274
57
 
275
- def configure_provider(ruby_config, provider, api_key)
276
- case provider
277
- when 'gemini', 'openai', 'openrouter'
278
- configure_remote_provider(ruby_config, provider, api_key)
279
- when 'anthropic'
280
- ruby_config.anthropic_api_key = api_key
281
- when 'ollama'
282
- ruby_config.ollama_api_base = @config.ollama_api_base
283
- else
284
- raise ConfigError, "Unsupported LLM provider: #{provider.inspect}"
58
+ # A pinned route giving up is not an API error for the pipeline but
59
+ # "repair impossible": the same fate as an invalid result.
60
+ def call_llm(prompt, pinned:)
61
+ response = @router.call(stage: prompt.stage, request_chars: prompt.chars, pinned: pinned) do |route, timeout|
62
+ @client.request(prompt, candidate: route.candidate, key: route.key, key_index: route.key_index,
63
+ timeout: timeout)
285
64
  end
286
- end
287
-
288
- def configure_remote_provider(ruby_config, provider, api_key)
289
- ruby_config.public_send("#{provider}_api_key=", api_key)
290
- return unless Aireview::Utils.present?(@config.llm_api_base)
291
-
292
- ruby_config.public_send("#{provider}_api_base=", @config.llm_api_base)
65
+ response.content
66
+ rescue RouteExhaustedError => e
67
+ raise RepairImpossibleError, e.message
293
68
  end
294
69
  end
295
70
  end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+ require_relative 'errors'
3
+ require_relative 'utils'
4
+ require_relative 'model_candidate'
5
+ require_relative 'stages'
6
+
7
+ module Aireview
8
+ # A routing plan of independent chains: every stage has its own primary
9
+ # model and reserves in walking order. Critique does not depend on which
10
+ # model answered in Generate.
11
+ #
12
+ # The plan interface (ModelPool implements it too): chain(stage),
13
+ # critique_chain(after:), primary(stage), weaker?, signature, rule,
14
+ # pool?, pool_stage?, pool_member?, start_used?, warnings.
15
+ class StageChains
16
+ # settings — per stage: provider, model, fallbacks (the raw list from the
17
+ # config), max_prompt_chars. only_primary keeps one model.
18
+ def self.build(settings, only_primary: false)
19
+ chains = settings.to_h do |stage, stage_settings|
20
+ [stage.to_s, stage_chain(stage.to_s, stage_settings, only_primary: only_primary)]
21
+ end
22
+ new(chains)
23
+ end
24
+
25
+ def self.stage_chain(stage, settings, only_primary:)
26
+ primary = ModelCandidate.new(
27
+ provider: settings.fetch(:provider).to_s,
28
+ model: settings[:model],
29
+ max_prompt_chars: settings.fetch(:max_prompt_chars)
30
+ )
31
+ return [primary] if only_primary
32
+
33
+ [primary, *fallbacks(stage, Array(settings[:fallbacks]), primary)]
34
+ end
35
+
36
+ # A reserve without a provider inherits the stage provider, without a
37
+ # limit its limit.
38
+ def self.fallbacks(stage, items, primary)
39
+ items.each_with_index.map do |item, index|
40
+ item = ModelCandidate.parse_item(item)
41
+ name = "llm.#{stage}.fallbacks[#{index}]"
42
+ raise ConfigError, "#{name} must be a model name or a hash with model" unless item.is_a?(Hash)
43
+ raise ConfigError, "#{name}.model is required" if Aireview::Utils.blank?(item['model'])
44
+
45
+ limit = item['max_prompt_chars']
46
+ ModelCandidate.new(
47
+ provider: (item['provider'] || primary.provider).to_s,
48
+ model: item['model'].to_s,
49
+ max_prompt_chars: limit.nil? ? primary.max_prompt_chars : positive_limit(limit, name)
50
+ )
51
+ end
52
+ end
53
+
54
+ def self.positive_limit(value, name)
55
+ integer = Integer(value, exception: false)
56
+ return integer if integer&.positive?
57
+
58
+ raise ConfigError, "#{name}.max_prompt_chars must be a positive integer, got #{value.inspect}"
59
+ end
60
+
61
+ def initialize(chains)
62
+ @chains = chains
63
+ end
64
+
65
+ def chain(stage)
66
+ @chains.fetch(stage.to_s) { raise ArgumentError, "unknown LLM stage #{stage.inspect}" }
67
+ end
68
+
69
+ def stage?(stage)
70
+ @chains.key?(stage.to_s)
71
+ end
72
+
73
+ def critique_chain(after:)
74
+ chain('critique')
75
+ end
76
+
77
+ def primary(stage)
78
+ chain(stage).first
79
+ end
80
+
81
+ def weaker?(_critique_candidate, _generate_candidate)
82
+ false
83
+ end
84
+
85
+ def signature
86
+ nil
87
+ end
88
+
89
+ def rule
90
+ nil
91
+ end
92
+
93
+ def pool?
94
+ false
95
+ end
96
+
97
+ def pool_stage?(_stage)
98
+ false
99
+ end
100
+
101
+ def pool_member?(_model)
102
+ false
103
+ end
104
+
105
+ def start_used?(_stage)
106
+ false
107
+ end
108
+
109
+ def warnings
110
+ []
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aireview
4
+ # The two review stages: Generate finds the findings, Critique checks them.
5
+ # The single definition; inside the code a stage is a string.
6
+ STAGES = %w[generate critique].freeze
7
+ end
@@ -14,5 +14,34 @@ module Aireview
14
14
  def presence(value)
15
15
  present?(value) ? value.to_s.strip : nil
16
16
  end
17
+
18
+ # Merges settings by key: nested hashes merge, everything else (arrays
19
+ # included) is replaced by the right side as a whole.
20
+ def deep_merge(left, right)
21
+ left.merge(right) do |_, old_value, new_value|
22
+ if old_value.is_a?(Hash) && new_value.is_a?(Hash)
23
+ deep_merge(old_value, new_value)
24
+ else
25
+ new_value
26
+ end
27
+ end
28
+ end
29
+
30
+ # Hash keys are strings at any depth: YAML, env and LLM answers arrive in
31
+ # different shapes, everything downstream works with one.
32
+ def normalize_hash(value)
33
+ case value
34
+ when Hash
35
+ value.each_with_object({}) { |(key, inner), result| result[key.to_s] = normalize_hash(inner) }
36
+ when Array
37
+ value.map { |item| normalize_hash(item) }
38
+ else
39
+ value
40
+ end
41
+ end
42
+
43
+ def dig(data, *keys)
44
+ keys.reduce(data) { |accumulator, key| accumulator.is_a?(Hash) ? accumulator[key] : nil }
45
+ end
17
46
  end
18
47
  end
@@ -1,4 +1,4 @@
1
1
  # frozen_string_literal: true
2
2
  module Aireview
3
- VERSION = '0.2.1'
3
+ VERSION = '2.0.0'
4
4
  end
data/lib/aireview.rb CHANGED
@@ -16,6 +16,7 @@ require_relative 'aireview/secret_scrubber'
16
16
  require_relative 'aireview/context_builder'
17
17
  require_relative 'aireview/reviewer'
18
18
  require_relative 'aireview/review_pipeline'
19
+ require_relative 'aireview/model_checker'
19
20
  require_relative 'aireview/gitlab_client'
20
21
  require_relative 'aireview/jira_client'
21
22
  require_relative 'aireview/review_marker'
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: aireview
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.1
4
+ version: 2.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Denis Levenko
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-09-14 00:00:00.000000000 Z
11
+ date: 2026-09-19 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: dotenv
@@ -31,6 +31,9 @@ dependencies:
31
31
  - - "~>"
32
32
  - !ruby/object:Gem::Version
33
33
  version: '2.14'
34
+ - - ">="
35
+ - !ruby/object:Gem::Version
36
+ version: 2.14.4
34
37
  type: :runtime
35
38
  prerelease: false
36
39
  version_requirements: !ruby/object:Gem::Requirement
@@ -38,6 +41,9 @@ dependencies:
38
41
  - - "~>"
39
42
  - !ruby/object:Gem::Version
40
43
  version: '2.14'
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: 2.14.4
41
47
  - !ruby/object:Gem::Dependency
42
48
  name: ruby_llm
43
49
  requirement: !ruby/object:Gem::Requirement
@@ -68,10 +74,15 @@ files:
68
74
  - README.md
69
75
  - bin/aireview
70
76
  - config/.aireview.yml.example
77
+ - config/defaults.yml
71
78
  - lib/aireview.rb
79
+ - lib/aireview/candidate_checker.rb
72
80
  - lib/aireview/cli.rb
73
81
  - lib/aireview/config.rb
82
+ - lib/aireview/config_fallbacks.rb
83
+ - lib/aireview/config_layers.rb
74
84
  - lib/aireview/config_limits.rb
85
+ - lib/aireview/config_loader.rb
75
86
  - lib/aireview/context_budget.rb
76
87
  - lib/aireview/context_builder.rb
77
88
  - lib/aireview/diff_fetcher.rb
@@ -79,17 +90,27 @@ files:
79
90
  - lib/aireview/errors.rb
80
91
  - lib/aireview/gitlab_client.rb
81
92
  - lib/aireview/jira_client.rb
93
+ - lib/aireview/llm_client.rb
94
+ - lib/aireview/llm_failure.rb
95
+ - lib/aireview/llm_router.rb
96
+ - lib/aireview/model_candidate.rb
97
+ - lib/aireview/model_checker.rb
98
+ - lib/aireview/model_pool.rb
99
+ - lib/aireview/model_state.rb
82
100
  - lib/aireview/mr_parser.rb
83
101
  - lib/aireview/output_schemas.rb
84
102
  - lib/aireview/prompts/critique.txt
85
103
  - lib/aireview/prompts/generate.txt
86
104
  - lib/aireview/publisher.rb
105
+ - lib/aireview/result_parser.rb
87
106
  - lib/aireview/review_marker.rb
88
107
  - lib/aireview/review_pipeline.rb
89
108
  - lib/aireview/review_renderer.rb
90
109
  - lib/aireview/review_schemas.rb
91
110
  - lib/aireview/reviewer.rb
92
111
  - lib/aireview/secret_scrubber.rb
112
+ - lib/aireview/stage_chains.rb
113
+ - lib/aireview/stages.rb
93
114
  - lib/aireview/utils.rb
94
115
  - lib/aireview/version.rb
95
116
  homepage: https://github.com/DenisDenis9331/aireview