aireview 0.2.1 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6f428bec4ae96692e65cd9f219856dff87e04e75dec45448dc9ef6806dd6694a
4
- data.tar.gz: 515afe0ed7450df6cfcb2ce12040780a9c0f45bbbc45fef08f19c4601239908b
3
+ metadata.gz: 394d2acbc7ed98d03e3a1f87e61fd9f07e1985b40ad47e4086fce47e5a18f5be
4
+ data.tar.gz: 85598cb2c2e81f95ff59e40bb5d2cef47d4813caf49f0ef318cf5931651adbf7
5
5
  SHA512:
6
- metadata.gz: 97cf66d34a0dee0d02823a3d37ad37b85aefeb4a0e2b997cd9f8c0780c3df9816d7c95f520709d3cc668d3910bf749f26553da2001b0878c0935bf117b6a486d
7
- data.tar.gz: c9ede525f15c46b1ce3371c7a77394933599bc71c6900f79dcea5a6780a29621378e7cd6d5652480921cefe84e6513c4865bd81265523fb6436752f9b35a422d
6
+ metadata.gz: c45f78bc422def8e2392499ba1dc0e7f87e67183bad8b9e9a29b3b242512caf0a006511922ad32f6a237d6e971578861a86ebb5107790e86e4c3e34598d9c6e6
7
+ data.tar.gz: fb33990e5743821e8bef7900241ff6b5b7d7f575d3a22ac3eb645990c7050e792d46927c71d401ab659e200366e23acacb9d4f62eef6c74e70e78e7a76bc9111
data/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0
4
+
5
+ - Fallback models per stage (`llm.<stage>.fallbacks`,
6
+ `LLM_<STAGE>_FALLBACK_MODEL`) and fallback API keys (`GEMINI_API_KEYS`).
7
+ An overloaded model or a timeout switches to the next model after one
8
+ short retry; an exhausted daily quota switches to the next key without
9
+ waiting; the last route keeps the full retry schedule.
10
+ - A time budget for the LLM part of the run (`llm.time_budget`,
11
+ `LLM_TIME_BUDGET`, 30 minutes by default) bounds pauses and request
12
+ timeouts; the built-in RubyLLM retries are disabled.
13
+ - The report names the fallback model a stage used; `--dry-run` prints the
14
+ chains and the number of keys; `--no-fallbacks` disables the reserves.
15
+ - Candidates are checked against the diff shown to the model: a file outside
16
+ the merge request drops the candidate, a line outside the shown hunks is
17
+ reset, a quote not found in the diff is marked in the report and reported
18
+ to Critique.
19
+ - Critique is skipped when Generate returns no candidates.
20
+ - Report limits are applied after selecting the findings to show, so a
21
+ finding that is never shown no longer displaces a useful one.
22
+
3
23
  ## 0.2.1
4
24
 
5
25
  - An overloaded LLM (503) gets a fifth attempt: the pauses are now about
data/README.md CHANGED
@@ -59,15 +59,19 @@ JIRA_URL=https://jira.company.com
59
59
  JIRA_LOGIN=user
60
60
  JIRA_PASSWORD=xxx
61
61
  GEMINI_API_KEY=xxx
62
+ GEMINI_API_KEYS=xxx,yyy
62
63
  LLM_PROVIDER=gemini
63
64
  LLM_TEMPERATURE=0
64
- LLM_TIMEOUT=60
65
+ LLM_TIMEOUT=120
66
+ LLM_TIME_BUDGET=1800
65
67
  LLM_HTTP_PROXY=http://127.0.0.1:8888
66
68
  LLM_GENERATE_PROVIDER=gemini
67
69
  LLM_GENERATE_MODEL=gemini-3.7-flash
70
+ LLM_GENERATE_FALLBACK_MODEL=gemini-3.8-flash
68
71
  LLM_GENERATE_TEMPERATURE=0.3
69
72
  LLM_CRITIQUE_PROVIDER=gemini
70
73
  LLM_CRITIQUE_MODEL=gemini-3.8-flash
74
+ LLM_CRITIQUE_FALLBACK_MODEL=gemini-3.7-flash
71
75
  LLM_CRITIQUE_TEMPERATURE=0
72
76
  REVIEW_LANGUAGE=ru
73
77
  REVIEW_MODE=update
@@ -162,10 +166,14 @@ To run both stages locally, set `ollama` in both provider variables. The
162
166
  address with `/v1` matches the
163
167
  [Ollama configuration in RubyLLM](https://rubyllm.com/configuration/#provider-configuration).
164
168
  `LLM_TIMEOUT` sets the timeout of every LLM request in seconds; for a slow
165
- local model it can be raised. It does not apply to an "overloaded" (503)
166
- answer from the provider: such a request gets up to five attempts, the
167
- original one and four retries with pauses of about 2, 5, 5 and 5 minutes, and
168
- only the failed stage is repeated, not the whole run.
169
+ local model it can be raised, but not without limit: a hung request holds
170
+ the job for exactly that long while a fallback model sits idle. An
171
+ "overloaded" (503) answer from the provider or a timeout does not fail the
172
+ run right away: without a fallback model such a request gets up to five
173
+ attempts, the original one and four retries with pauses of about 2, 5, 5 and
174
+ 5 minutes; with a fallback model, one short retry and a switch (see
175
+ "Fallback models and keys"). Only the failed stage is repeated, not the
176
+ whole run.
169
177
 
170
178
  Project rules live in `.aireview.yml`. In YAML the `generate.model` and
171
179
  `critique.model` settings are required for each stage and are not inherited
@@ -213,13 +221,19 @@ context:
213
221
  llm:
214
222
  provider: gemini
215
223
  temperature: 0
216
- timeout: 60
224
+ timeout: 120
225
+ time_budget: 1800
217
226
  http_proxy: http://127.0.0.1:8888
218
227
  max_prompt_chars: 400000
219
228
  generate:
220
229
  provider: gemini
221
230
  model: gemini-3.7-flash
222
231
  temperature: 0.3
232
+ fallbacks:
233
+ - gemini-3.8-flash
234
+ - provider: ollama
235
+ model: qwen2.5-coder:7b
236
+ max_prompt_chars: 20000
223
237
  critique:
224
238
  provider: ollama
225
239
  model: qwen2.5-coder:7b
@@ -264,6 +278,77 @@ with an error instead of silently reviewing less. Raise the limits or extend
264
278
  `ignore_paths`. `--dry-run` prints the sizes of every part and the coverage;
265
279
  `--verbose` logs them during a real run.
266
280
 
281
+ ### Fallback models and keys
282
+
283
+ The primary model can sit under load (503) for half a day, and a key can run
284
+ out of its daily quota. Neither is cured by waiting, so a stage can have a
285
+ fallback model and a provider can have a fallback key:
286
+
287
+ - `llm.generate.fallbacks` / `llm.critique.fallbacks` in YAML or
288
+ `LLM_GENERATE_FALLBACK_MODEL` / `LLM_CRITIQUE_FALLBACK_MODEL` in the
289
+ environment. One model or several separated by commas, in the order they
290
+ are tried. The provider goes before a slash (`ollama/qwen2.5-coder:7b`);
291
+ without it the stage provider is used. A fallback model can have its own
292
+ `max_prompt_chars`: when the assembled request does not fit, that model is
293
+ skipped, the context is not cut for it.
294
+ - `GEMINI_API_KEYS=key1,key2`: keys in order of preference; `GEMINI_API_KEY`
295
+ still works and means a single key. Google counts quotas per Google Cloud
296
+ project and per model, so a reserve key only makes sense from another
297
+ project. Use fallback credentials in compliance with the provider's quota
298
+ and billing terms: Google's
299
+ [API limitations](https://developers.google.com/terms#api_limitations)
300
+ forbid circumventing its limits regardless of the project's billing. Keys
301
+ live only in the environment, never in `.aireview.yml`.
302
+
303
+ What happens on which error:
304
+
305
+ | Error | Reaction |
306
+ |---|---|
307
+ | Daily quota (`quotaId` like `…PerDay…` in Google's answer) | No retries: the next key on the same model. The "key + model" pair is remembered until the end of the run so that Critique and the JSON repair do not hit it again. Out of keys: the next model with the first key. |
308
+ | Per-minute limit (429 with a "retry in N s" hint) | Retry after the hinted delay; one retry while there is somewhere to switch to, up to three on the last route. Then the next key, then the next model. |
309
+ | Overloaded (503) or timeout | One short retry (~30 s) and the next model with the same key. The last model in the chain gets the full 2/5/5/5 minute schedule. |
310
+ | Other API errors (schema, context length, auth) | The run fails right away: a fallback model would answer the same. |
311
+
312
+ All of this is bounded by a time budget for the LLM part of the run:
313
+ `llm.time_budget` / `LLM_TIME_BUDGET`, 1800 seconds by default. A pause that
314
+ does not fit into the remainder is skipped and the request timeout is capped
315
+ by the remainder; when the time is up, the run fails with an error listing
316
+ everything that was tried. Because of this keep `LLM_TIMEOUT` around 120–300
317
+ seconds: one hung request must not eat the whole budget.
318
+
319
+ The review key (see "A single comment per merge request") is computed from
320
+ the configured primary model, not from the one that answered: a review made
321
+ by a fallback model is not rewritten on the next push without changes in the
322
+ MR, and adding a fallback model to the config does not re-run the review on
323
+ every open MR. When a stage went to a fallback model, the report ends with a
324
+ `Fallback model used: critique — …` line. A key switch stays in the log only,
325
+ and key values never reach the log. `--dry-run` prints the model chains and
326
+ the number of keys per provider, `--no-fallbacks` leaves one model and one
327
+ key per stage.
328
+
329
+ ### Checking that findings point at the diff
330
+
331
+ Between the passes every candidate is checked against the diff that actually
332
+ went to the model (after `ignore_paths`, secret scrubbing and the budget
333
+ cut). What is checked is the link to the code, not the bug itself:
334
+
335
+ - `file` is not among the changed files of the MR (renames included): the
336
+ candidate is dropped before Critique, there is nothing to check it
337
+ against. A file of the MR that was left out of the context by the budget
338
+ is a different case, see below.
339
+ - `line` falls into none of the shown hunks: the report shows the finding
340
+ without a line number.
341
+ - `quoted_code` is not found in the shown diff (compared ignoring
342
+ whitespace, on the new and on the old side): the candidate stays, but the
343
+ "Where" line of the report gets `(quote not found in the diff)`. Critique
344
+ cannot fix the quote, so the mark survives its keep.
345
+ - The file is shown partially, without a diff or not at all: the link cannot
346
+ be checked, and the coverage block of the report already says so.
347
+
348
+ Critique receives the result of the check in the candidate's `note` field
349
+ and decides keep/reject with it in mind. With `--no-critique` the check works
350
+ the same way, its marks just go straight to the report.
351
+
267
352
  ## Usage
268
353
 
269
354
  ```bash
@@ -284,6 +369,7 @@ bundle _2.3.26_ exec bin/aireview review https://gitlab.company.com/team/project
284
369
  - `--no-jira` turns off the Jira enrichment even when the MR carries an issue key.
285
370
  - `--dry-run` prints the LLM settings, the context sizes and coverage, and the Generate prompt, plus the Critique prompt unless `--no-critique` is given.
286
371
  - `--no-critique` skips the second pass and renders the Generate candidates directly.
372
+ - `--no-fallbacks` uses only the primary model and the first API key of each stage.
287
373
  - `--review-mode MODE` sets the behaviour when a review has already been published: `update` or `once`.
288
374
  - `--force` reviews again even when a review for this state of the MR is already published.
289
375
 
@@ -372,13 +458,13 @@ aireview:
372
458
  - stuck_or_timeout_failure
373
459
  ```
374
460
 
375
- `timeout: 45m` is a chosen ceiling, not a guarantee that every retry fits in:
376
- when the provider is overloaded, one stage can wait up to ~20 minutes of
377
- pauses plus up to five requests of `LLM_TIMEOUT` each, and there are two
378
- stages.
461
+ `timeout: 45m` is the job ceiling, not a guarantee: the LLM part of the run
462
+ is itself bounded by `LLM_TIME_BUDGET` (30 minutes by default, pauses and
463
+ fallback models included), the rest is reading the MR and Jira and
464
+ publishing.
379
465
 
380
- Set secrets such as `GITLAB_TOKEN`, `GEMINI_API_KEY` and the optional Jira
381
- credentials in the GitLab CI/CD variables. If the job should publish the result
466
+ Set secrets such as `GITLAB_TOKEN`, `GEMINI_API_KEY` / `GEMINI_API_KEYS` and
467
+ the optional Jira credentials in the GitLab CI/CD variables. If the job should publish the result
382
468
  back to the merge request, add `--post` to the review command.
383
469
 
384
470
  For runners where the LLM provider is only reachable over WireGuard, bring up a
@@ -27,5 +27,12 @@ review_instructions: |
27
27
 
28
28
  llm:
29
29
  provider: gemini
30
- model: gemini-3.7-flash
31
30
  temperature: 0.2
31
+ generate:
32
+ model: gemini-3.7-flash
33
+ fallbacks:
34
+ - gemini-3.8-flash
35
+ critique:
36
+ model: gemini-3.8-flash
37
+ fallbacks:
38
+ - gemini-3.7-flash
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+ require 'logger'
3
+ require 'set'
4
+
5
+ module Aireview
6
+ # Механическая проверка привязки кандидата к коду по тому диффу, который
7
+ # реально ушёл в модель: файл, строка, цитата. Проверяется привязка, а не
8
+ # сам баг: ненайденная цитата — повод критику присмотреться, не
9
+ # доказательство выдумки. Файл, которого нет среди изменений MR, — другое
10
+ # дело: такого кандидата проверять нечем, он отбрасывается.
11
+ class CandidateChecker
12
+ HUNK_HEADER = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/
13
+ FILE_HEADER = %r{^diff --git a/(.+?) b/(.+)$}
14
+ NOTE_QUOTE_NOT_FOUND = 'quoted_code not found in the diff shown to the model'
15
+ NOTE_LINE_RESET = 'line was outside the shown hunks and has been reset to null'
16
+ NOTE_NOT_VERIFIED = 'file shown partially or without a diff, location not verified'
17
+
18
+ # Один файл из контекста: диапазоны строк нового файла по показанным
19
+ # хункам и нормализованный текст каждого хунка для поиска цитаты —
20
+ # отдельно новая сторона (контекст + добавленные строки) и старая
21
+ # (контекст + удалённые), чтобы многострочная цитата из одной версии
22
+ # файла находилась целиком. Хунки не склеиваются: между ними пропущенный
23
+ # код, и цитата через границу хунка — не цитата.
24
+ Section = Struct.new(:ranges, :hunks, :partial, keyword_init: true) do
25
+ def include?(quote)
26
+ hunks.any? { |hunk| hunk[:new_text].include?(quote) || hunk[:old_text].include?(quote) }
27
+ end
28
+ end
29
+
30
+ def initialize(changes:, diff_text:, coverage:, logger: Logger.new($stderr))
31
+ @mr_paths = changes.flat_map { |change| change.values_at('old_path', 'new_path') }.compact.to_set
32
+ @coverage = coverage
33
+ @logger = logger
34
+ @sections = parse_sections(diff_text.to_s)
35
+ end
36
+
37
+ # Возвращает кандидатов с пометками: note для критика, quote_missing для
38
+ # отчёта, line сброшена в null, если не подтвердилась. Кандидаты с файлом
39
+ # не из MR отброшены.
40
+ def check(candidates)
41
+ candidates.filter_map do |candidate|
42
+ id = value(candidate, 'id')
43
+ path = resolve_path(value(candidate, 'file'))
44
+ unless path
45
+ @logger.warn("Candidate #{id} dropped: file #{value(candidate, 'file').inspect} is not in the merge request")
46
+ next
47
+ end
48
+
49
+ annotate(candidate.dup, id: id, section: @sections[path])
50
+ end
51
+ end
52
+
53
+ private
54
+
55
+ def annotate(candidate, id:, section:)
56
+ notes = []
57
+ if section.nil? || section.partial
58
+ @logger.debug("Candidate #{id}: file shown partially or not at all, location not verified")
59
+ notes << NOTE_NOT_VERIFIED
60
+ else
61
+ notes << check_line(candidate, id, section)
62
+ notes << check_quote(candidate, id, section)
63
+ end
64
+ notes.compact!
65
+ set(candidate, 'note', notes.join('; ')) unless notes.empty?
66
+ candidate
67
+ end
68
+
69
+ def check_line(candidate, id, section)
70
+ line = value(candidate, 'line')
71
+ return nil if line.nil?
72
+ return nil if line.is_a?(Integer) && section.ranges.any? { |range| range.cover?(line) }
73
+
74
+ @logger.warn("Candidate #{id}: line #{line} is outside the shown hunks, reset to null")
75
+ set(candidate, 'line', nil)
76
+ NOTE_LINE_RESET
77
+ end
78
+
79
+ def check_quote(candidate, id, section)
80
+ quote = normalize_text(value(candidate, 'quoted_code'))
81
+ return nil if quote.empty? || section.include?(quote)
82
+
83
+ @logger.warn("Candidate #{id}: quoted_code not found in the diff shown to the model")
84
+ set(candidate, 'quote_missing', true)
85
+ NOTE_QUOTE_NOT_FOUND
86
+ end
87
+
88
+ # Дифф уже собран под бюджет: у показанного частично файла хунков может
89
+ # не хватать, у файла без диффа хунков нет вовсе. Заголовки --- / +++
90
+ # бывают только между заголовком файла и первым @@; внутри хунка строка
91
+ # «+++ x» — это добавленный код «++ x».
92
+ def parse_sections(diff_text)
93
+ sections = {}
94
+ section = nil
95
+ hunk = nil
96
+ diff_text.each_line do |line|
97
+ if (header = line.match(FILE_HEADER))
98
+ section = Section.new(ranges: [], hunks: [], partial: false)
99
+ hunk = nil
100
+ header.captures.map(&:strip).each { |path| sections[path] = section }
101
+ elsif section && (hunk_header = line.match(HUNK_HEADER))
102
+ hunk = start_hunk(section, hunk_header)
103
+ elsif hunk
104
+ add_line(hunk, line)
105
+ end
106
+ end
107
+ mark_partial(sections)
108
+ normalize_hunks(sections)
109
+ end
110
+
111
+ # Хунк без новых строк (удаление, @@ -1 +0,0 @@) диапазона не даёт:
112
+ # строки 0 в новом файле нет.
113
+ def start_hunk(section, header)
114
+ start = header[1].to_i
115
+ length = header[2] ? header[2].to_i : 1
116
+ section.ranges << (start..(start + length - 1)) if length.positive?
117
+ hunk = {new_text: +'', old_text: +''}
118
+ section.hunks << hunk
119
+ hunk
120
+ end
121
+
122
+ def add_line(hunk, line)
123
+ if line.start_with?(' ')
124
+ hunk[:new_text] << line[1..]
125
+ hunk[:old_text] << line[1..]
126
+ elsif line.start_with?('+')
127
+ hunk[:new_text] << line[1..]
128
+ elsif line.start_with?('-')
129
+ hunk[:old_text] << line[1..]
130
+ end
131
+ end
132
+
133
+ def normalize_hunks(sections)
134
+ sections.each_value do |section|
135
+ section.hunks.each { |hunk| hunk.transform_values! { |text| normalize_text(text) } }
136
+ end
137
+ end
138
+
139
+ def mark_partial(sections)
140
+ partial = @coverage.files_partial.map { |file| file[:path] } + @coverage.files_unavailable
141
+ partial += @coverage.hunks_skipped.map { |skipped| skipped[:path] }
142
+ partial.each { |path| sections[path]&.partial = true }
143
+ end
144
+
145
+ # Сначала точное совпадение: каталоги a/ и b/ бывают настоящими. Префикс
146
+ # из заголовка диффа снимается только если точного пути в MR нет.
147
+ def resolve_path(path)
148
+ path = path.to_s.strip
149
+ return path if @mr_paths.include?(path)
150
+
151
+ stripped = path.sub(%r{\A(?:\./|[ab]/)}, '')
152
+ stripped if @mr_paths.include?(stripped)
153
+ end
154
+
155
+ def normalize_text(text)
156
+ text.to_s.gsub(/\s+/, ' ').strip
157
+ end
158
+
159
+ def value(hash, key)
160
+ hash[key] || hash[key.to_sym]
161
+ end
162
+
163
+ def set(hash, key, new_value)
164
+ hash[hash.key?(key.to_sym) ? key.to_sym : key] = new_value
165
+ end
166
+ end
167
+ end
data/lib/aireview/cli.rb CHANGED
@@ -68,7 +68,8 @@ module Aireview
68
68
  generate_model: options[:generate_model],
69
69
  critique_model: options[:critique_model],
70
70
  generate_temperature: options[:generate_temperature],
71
- critique_temperature: options[:critique_temperature]
71
+ critique_temperature: options[:critique_temperature],
72
+ no_fallbacks: options[:no_fallbacks] == true
72
73
  )
73
74
  config.require_llm_configuration!
74
75
  config
@@ -280,6 +281,10 @@ module Aireview
280
281
  parser.on('--no-critique', 'Skip critique pass and render Generate candidates directly') do
281
282
  options[:no_critique] = true
282
283
  end
284
+
285
+ parser.on('--no-fallbacks', 'Use only the primary model and the first API key of each stage') do
286
+ options[:no_fallbacks] = true
287
+ end
283
288
  end
284
289
 
285
290
  def add_publication_options(parser, options)
@@ -349,6 +354,7 @@ module Aireview
349
354
  --no-jira Disable Jira enrichment
350
355
  --dry-run Print prompts without LLM calls
351
356
  --no-critique Skip second LLM critique pass
357
+ --no-fallbacks Use only the primary model and the first API key of each stage
352
358
  --verbose Enable verbose logging
353
359
  -h, --help Show help
354
360
  HELP
@@ -5,11 +5,14 @@ require 'yaml'
5
5
  require_relative 'errors'
6
6
  require_relative 'utils'
7
7
  require_relative 'config_limits'
8
+ require_relative 'config_fallbacks'
8
9
 
9
10
  module Aireview
10
11
  class Config
11
12
  include ConfigLimits
13
+ include ConfigFallbacks
12
14
  extend ConfigLimits::ClassMethods
15
+ extend ConfigFallbacks::ClassMethods
13
16
 
14
17
  DEFAULT_SECRET_FILES = [
15
18
  '.env',
@@ -39,7 +42,8 @@ module Aireview
39
42
  'provider' => 'gemini',
40
43
  'temperature' => 0,
41
44
  'timeout' => 60,
42
- 'max_prompt_chars' => ConfigLimits::DEFAULT_MAX_PROMPT_CHARS
45
+ 'max_prompt_chars' => ConfigLimits::DEFAULT_MAX_PROMPT_CHARS,
46
+ 'time_budget' => ConfigFallbacks::DEFAULT_TIME_BUDGET
43
47
  },
44
48
  'context' => ConfigLimits::CONTEXT_DEFAULTS
45
49
  }.freeze
@@ -104,6 +108,7 @@ module Aireview
104
108
  .merge('llm' => llm_env_config(env))
105
109
  .merge(context_env_config(env))
106
110
  .merge(provider_key_env_config(env))
111
+ .merge(provider_keys_env_config(env))
107
112
  .merge(generic_api_key_env_config(env))
108
113
  end
109
114
 
@@ -120,6 +125,7 @@ module Aireview
120
125
  'temperature' => parse_float(env['LLM_TEMPERATURE']),
121
126
  'timeout' => parse_float(env['LLM_TIMEOUT']),
122
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'),
123
129
  'generate' => llm_stage_env_config(env, 'GENERATE'),
124
130
  'critique' => llm_stage_env_config(env, 'CRITIQUE')
125
131
  }.compact.reject { |key, value| %w[generate critique].include?(key) && value.empty? }
@@ -130,7 +136,8 @@ module Aireview
130
136
  'provider' => env["LLM_#{stage}_PROVIDER"],
131
137
  'model' => env["LLM_#{stage}_MODEL"],
132
138
  'temperature' => parse_float(env["LLM_#{stage}_TEMPERATURE"]),
133
- 'max_prompt_chars' => parse_integer(env["LLM_#{stage}_MAX_PROMPT_CHARS"], "LLM_#{stage}_MAX_PROMPT_CHARS")
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)
134
141
  }.compact
135
142
  end
136
143
 
@@ -187,23 +194,25 @@ module Aireview
187
194
  @logger = logger
188
195
  end
189
196
 
197
+ # Переопределения из CLI меняют только основную модель стадии, запасные
198
+ # из конфига остаются; no_fallbacks оставляет одну модель и один ключ.
190
199
  def with_overrides(
191
200
  generate_model: nil,
192
201
  critique_model: nil,
193
202
  generate_temperature: nil,
194
- critique_temperature: nil
203
+ critique_temperature: nil,
204
+ no_fallbacks: false
195
205
  )
196
206
  llm_config = {
197
207
  'generate' => stage_overrides(model: generate_model, temperature: generate_temperature),
198
208
  'critique' => stage_overrides(model: critique_model, temperature: critique_temperature)
199
209
  }.reject { |_, overrides| overrides.empty? }
200
- return self if llm_config.empty?
210
+ overrides = {}
211
+ overrides['llm'] = llm_config unless llm_config.empty?
212
+ overrides['fallbacks_disabled'] = true if no_fallbacks
213
+ return self if overrides.empty?
201
214
 
202
- merged = self.class.deep_merge(
203
- @data,
204
- 'llm' => llm_config
205
- )
206
- self.class.new(merged, config_path: config_path, logger: @logger)
215
+ self.class.new(self.class.deep_merge(@data, overrides), config_path: config_path, logger: @logger)
207
216
  end
208
217
 
209
218
  def gitlab_url
@@ -334,20 +343,21 @@ module Aireview
334
343
  def require_llm_configuration!
335
344
  require_models!
336
345
 
337
- missing_keys = {
338
- 'generate' => generate_provider,
339
- 'critique' => critique_provider
340
- }.filter_map do |stage, provider|
341
- next if provider.to_s == 'ollama'
342
- next if Aireview::Utils.present?(provider_api_key(provider))
343
-
344
- "#{stage}: API key is required for provider #{provider.inspect}"
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}" }
345
349
  end
346
350
  raise ConfigError, missing_keys.join(', ') unless missing_keys.empty?
347
351
  end
348
352
 
349
353
  private
350
354
 
355
+ def provider_keys_present?(provider)
356
+ return true if ConfigFallbacks::KEYLESS_PROVIDERS.include?(provider.to_s)
357
+
358
+ provider_api_keys(provider).any? { |key| Aireview::Utils.present?(key) }
359
+ end
360
+
351
361
  def stage_overrides(model:, temperature:)
352
362
  {
353
363
  'model' => model,
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aireview
4
+ # Резервы на случай, когда основная модель лежит или у ключа кончилась
5
+ # квота: цепочка моделей на стадию и список ключей на провайдера. Модели
6
+ # задаются в .aireview.yml или env, ключи — только в env.
7
+ 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
+ KEYLESS_PROVIDERS = %w[ollama].freeze
16
+ PROVIDER_KEYS_MAPPING = {
17
+ 'gemini' => 'GEMINI_API_KEYS'
18
+ }.freeze
19
+ DEFAULT_TIME_BUDGET = 1_800
20
+
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
44
+ end
45
+
46
+ # Первый элемент — основная модель стадии, дальше запасные в порядке
47
+ # обхода. Запасная без провайдера наследует провайдера стадии, без
48
+ # max_prompt_chars — лимит стадии.
49
+ 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)]
59
+ end
60
+
61
+ # Ключи в порядке предпочтения; для провайдера без ключей — один nil,
62
+ # чтобы обход цепочки не зависел от провайдера.
63
+ def provider_api_keys(provider)
64
+ provider = provider.to_s
65
+ return [nil] if KEYLESS_PROVIDERS.include?(provider)
66
+
67
+ keys = Array(@data["#{provider}_api_keys"]).compact
68
+ keys = [provider_api_key(provider)].compact if keys.empty?
69
+ fallbacks_disabled? ? keys.first(1) : keys
70
+ end
71
+
72
+ def fallbacks_disabled?
73
+ @data['fallbacks_disabled'] == true
74
+ end
75
+
76
+ def fallback_names(stage)
77
+ stage_chain(stage).drop(1).map(&:to_s)
78
+ end
79
+
80
+ # Только число ключей на провайдера — для --dry-run; значения наружу не
81
+ # выходят.
82
+ def api_key_counts(stages)
83
+ providers = stages.flat_map { |stage| stage_chain(stage).map(&:provider) }.uniq
84
+ providers.reject { |provider| KEYLESS_PROVIDERS.include?(provider.to_s) }
85
+ .to_h { |provider| [provider, provider_api_keys(provider).size] }
86
+ end
87
+
88
+ # Общий потолок на все LLM-запросы прогона вместе с паузами между
89
+ # попытками: цепочка резервов не должна съедать всю CI-джобу.
90
+ def llm_time_budget
91
+ positive_integer!(dig('llm', 'time_budget') || DEFAULT_TIME_BUDGET, 'llm.time_budget')
92
+ end
93
+
94
+ private
95
+
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
+ )
109
+ end
110
+ end
111
+ end
112
+ end
@@ -23,7 +23,7 @@ module Aireview
23
23
 
24
24
  # Контекст одного прогона: обе стадии получают одинаковые MR, Jira и дифф,
25
25
  # усечённые один раз под самую тесную из стадий.
26
- Context = Struct.new(:user_prompt, :coverage, :sizes, keyword_init: true)
26
+ Context = Struct.new(:user_prompt, :diff_text, :coverage, :sizes, keyword_init: true)
27
27
 
28
28
  def initialize(config:, logger: Logger.new($stderr))
29
29
  @config = config
@@ -49,7 +49,7 @@ module Aireview
49
49
 
50
50
  sizes = context_sizes(fixed: fixed, packed: packed, budget: budget, diff_budget: diff_budget, critique: critique)
51
51
  log_sizes(sizes)
52
- Context.new(user_prompt: fixed + packed.text, coverage: coverage, sizes: sizes)
52
+ Context.new(user_prompt: fixed + packed.text, diff_text: packed.text, coverage: coverage, sizes: sizes)
53
53
  end
54
54
 
55
55
  def build_generate_prompt(context)