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/llm_router.rb
CHANGED
|
@@ -2,12 +2,17 @@
|
|
|
2
2
|
require 'set'
|
|
3
3
|
require_relative 'errors'
|
|
4
4
|
require_relative 'llm_failure'
|
|
5
|
+
require_relative 'model_state'
|
|
5
6
|
|
|
6
7
|
module Aireview
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
|
|
8
|
+
# Walks the models and keys of a stage. An overload is a property of the
|
|
9
|
+
# model, a quota is a property of "key + model", so a 503 switches the
|
|
10
|
+
# model and a quota switches the key. An overloaded model goes into
|
|
11
|
+
# quarantine and is skipped until it expires; once the chain has been
|
|
12
|
+
# walked, the walk goes round again over the models whose quarantine has
|
|
13
|
+
# expired. Two limits: the time budget of the run and the number of
|
|
14
|
+
# requests sent per model per stage (ModelState).
|
|
15
|
+
class LlmRouter # rubocop:disable Metrics/ClassLength
|
|
11
16
|
Route = Struct.new(:candidate, :candidate_index, :key, :key_index, :key_count, keyword_init: true) do
|
|
12
17
|
def fallback?
|
|
13
18
|
candidate_index.positive?
|
|
@@ -18,278 +23,424 @@ module Aireview
|
|
|
18
23
|
end
|
|
19
24
|
end
|
|
20
25
|
|
|
21
|
-
|
|
26
|
+
# A journal entry about a visit to a route, for the error message: either
|
|
27
|
+
# the kind of failure with the number of requests, or a note on why the
|
|
28
|
+
# route was skipped.
|
|
29
|
+
Visit = Struct.new(:route, :kind, :tries, :error, :note, keyword_init: true) do
|
|
22
30
|
def to_s
|
|
23
|
-
"#{route}: #{note || "#{LlmRouter::KIND_LABELS.fetch(kind)} after #{
|
|
31
|
+
"#{route}: #{note || "#{LlmRouter::KIND_LABELS.fetch(kind)} after #{tries} attempt(s)"}"
|
|
24
32
|
end
|
|
25
33
|
end
|
|
26
34
|
|
|
35
|
+
Slot = Struct.new(:candidate, :index)
|
|
36
|
+
# The key the next model of the same provider continues from.
|
|
37
|
+
Carry = Struct.new(:provider, :key_index)
|
|
38
|
+
Delay = Struct.new(:seconds, :source)
|
|
39
|
+
|
|
40
|
+
SWITCH_HINT = 'Try again later or switch model via --generate-model/--critique-model.'
|
|
41
|
+
|
|
27
42
|
KIND_LABELS = {
|
|
28
43
|
daily_quota: 'daily quota exhausted',
|
|
29
44
|
rate_limit: 'rate limited',
|
|
30
45
|
overloaded: 'overloaded',
|
|
31
|
-
timeout: 'timed out'
|
|
46
|
+
timeout: 'timed out',
|
|
47
|
+
unavailable: 'model unavailable'
|
|
32
48
|
}.freeze
|
|
33
|
-
SWITCH_HINT = 'Try again later or switch model via --generate-model/--critique-model.'
|
|
34
49
|
|
|
35
|
-
|
|
36
|
-
#
|
|
50
|
+
MAX_ATTEMPTS_PER_MODEL = ModelState::MAX_REQUESTS_PER_STAGE
|
|
51
|
+
# The first failure of a visit to a model gets one short retry, the
|
|
52
|
+
# second one a quarantine and the next model.
|
|
37
53
|
SHORT_RETRY_DELAY = 30.0
|
|
38
54
|
SHORT_RETRY_JITTER_RANGE = 0.85..1.15
|
|
39
|
-
MAX_RATE_LIMIT_RETRIES = 3
|
|
40
55
|
RATE_LIMIT_BASE_DELAY = 2.0
|
|
41
56
|
RATE_LIMIT_JITTER_RANGE = 2.0..5.0
|
|
42
57
|
PROVIDER_RETRY_DELAY_MULTIPLIER_RANGE = 2.0..2.4
|
|
43
|
-
OVERLOADED_RETRY_DELAYS = [120.0, 300.0, 300.0, 300.0].freeze
|
|
44
|
-
OVERLOADED_RETRY_JITTER_RANGE = 0.85..1.15
|
|
45
58
|
RETRY_WAIT_LOG_FORMAT = 'LLM %<stage>s request will sleep %<delay>.1fs before retry%<source>s ' \
|
|
46
|
-
'(
|
|
59
|
+
'(request %<next_request>d/%<max_requests>d of the model in this stage, model=%<model>s)'
|
|
47
60
|
|
|
48
|
-
|
|
61
|
+
# clock and sleeper are injected in tests: the schedule is checked without
|
|
62
|
+
# real waiting.
|
|
63
|
+
def initialize(config:, logger:, routing: nil, clock: nil, sleeper: nil)
|
|
49
64
|
@config = config
|
|
65
|
+
@routing = routing || config.routing
|
|
50
66
|
@logger = logger
|
|
51
|
-
@
|
|
67
|
+
@clock = clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
|
|
68
|
+
@sleeper = sleeper || ->(seconds) { sleep(seconds) }
|
|
69
|
+
@models = Hash.new { |states, name| states[name] = ModelState.new }
|
|
52
70
|
@cursor = {}
|
|
53
71
|
@used = {}
|
|
54
72
|
@deadline = nil
|
|
55
73
|
end
|
|
56
74
|
|
|
57
|
-
#
|
|
58
|
-
#
|
|
59
|
-
#
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
75
|
+
# The block receives a route and a request timeout, makes the request and
|
|
76
|
+
# returns the answer. An error raised by the block is classified, then
|
|
77
|
+
# comes a retry, another key, another model, or ApiError once the routes
|
|
78
|
+
# are exhausted. pinned — only the model that answered last in this
|
|
79
|
+
# stage (the JSON repair): its failure is RouteExhaustedError, and the
|
|
80
|
+
# stage restarts on another model.
|
|
81
|
+
def call(stage:, request_chars:, pinned: false, &request)
|
|
82
|
+
@deadline ||= now + @config.llm_time_budget
|
|
83
|
+
visits = []
|
|
84
|
+
noted = Set.new
|
|
85
|
+
carry = nil
|
|
86
|
+
loop do
|
|
87
|
+
slots = available_slots(stage, request_chars, pinned, visits, noted)
|
|
88
|
+
slot = slots.empty? ? nil : ready_slot(stage, slots, visits)
|
|
89
|
+
raise exhausted_error(stage, visits, pinned) unless slot
|
|
90
|
+
|
|
91
|
+
status, value = try_candidate(stage, slot, visits, carry, &request)
|
|
70
92
|
return value if status == :ok
|
|
71
93
|
|
|
72
|
-
|
|
94
|
+
carry = value
|
|
73
95
|
end
|
|
74
|
-
|
|
75
|
-
raise ApiError, exhausted_message(stage, attempts)
|
|
76
96
|
end
|
|
77
97
|
|
|
78
|
-
#
|
|
98
|
+
# Stages answered by a model other than the primary one, for the report.
|
|
79
99
|
def fallback_models
|
|
80
100
|
@used.select { |_, route| route.fallback? }.transform_values { |route| route.candidate.to_s }
|
|
81
101
|
end
|
|
82
102
|
|
|
103
|
+
# The model that answered last in the stage and its place in the chain.
|
|
104
|
+
def answered(stage)
|
|
105
|
+
route = @used[stage.to_s]
|
|
106
|
+
return nil unless route
|
|
107
|
+
|
|
108
|
+
"#{route.candidate} (#{route.candidate_index + 1}/#{chain_for(stage.to_s).size})"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Critique answered with a model below Generate in the pool, for the report.
|
|
112
|
+
def critique_weaker?
|
|
113
|
+
generate = @used['generate']
|
|
114
|
+
critique = @used['critique']
|
|
115
|
+
return false unless generate && critique
|
|
116
|
+
|
|
117
|
+
@routing.weaker?(critique.candidate, generate.candidate)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Excludes the model that answered until the end of the stage: its result
|
|
121
|
+
# is invalid (not JSON, not the schema) even after the repair. The next
|
|
122
|
+
# request of the stage goes to another model. Returns the excluded model;
|
|
123
|
+
# nil — nothing to exclude.
|
|
124
|
+
def exclude_answered(stage:, reason:)
|
|
125
|
+
route = @used[stage.to_s]
|
|
126
|
+
return nil unless route
|
|
127
|
+
|
|
128
|
+
state(route.candidate).exclude_for_stage(stage, reason)
|
|
129
|
+
@logger.warn("LLM #{stage}: #{route.candidate} excluded for this stage: #{reason}")
|
|
130
|
+
route.candidate
|
|
131
|
+
end
|
|
132
|
+
|
|
83
133
|
def remaining_time
|
|
84
|
-
@deadline ? @deadline -
|
|
134
|
+
@deadline ? @deadline - now : @config.llm_time_budget.to_f
|
|
85
135
|
end
|
|
86
136
|
|
|
87
137
|
private
|
|
88
138
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
139
|
+
def state(candidate)
|
|
140
|
+
@models[candidate.to_s]
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# The models that can still be tried, in walking order. A skipped model
|
|
144
|
+
# enters the journal once per call, and only if it is not already there
|
|
145
|
+
# with an error of this same call.
|
|
146
|
+
def available_slots(stage, request_chars, pinned, visits, noted)
|
|
147
|
+
chain = pinned ? pinned_chain(stage) : ordered_chain(stage)
|
|
148
|
+
chain.filter_map do |candidate, index|
|
|
149
|
+
note = skip_reason(stage, candidate, request_chars)
|
|
150
|
+
next Slot.new(candidate, index) unless note
|
|
151
|
+
|
|
152
|
+
note_skip(visits, noted, candidate, index, note)
|
|
153
|
+
nil
|
|
102
154
|
end
|
|
103
|
-
[:next_model, carried]
|
|
104
155
|
end
|
|
105
156
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
157
|
+
def note_skip(visits, noted, candidate, index, note)
|
|
158
|
+
return unless noted.add?([index, note])
|
|
159
|
+
return if visits.any? { |visit| visit.error && visit.route.candidate == candidate }
|
|
160
|
+
|
|
161
|
+
visits << Visit.new(route: bare_route(candidate, index), note: note)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def bare_route(candidate, index)
|
|
165
|
+
Route.new(candidate: candidate, candidate_index: index, key_count: 1)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def skip_reason(stage, candidate, request_chars)
|
|
169
|
+
if request_chars > candidate.max_prompt_chars
|
|
170
|
+
"skipped, request #{request_chars} chars over max_prompt_chars=#{candidate.max_prompt_chars}"
|
|
171
|
+
else
|
|
172
|
+
state(candidate).skip_reason(stage)
|
|
173
|
+
end
|
|
111
174
|
end
|
|
112
175
|
|
|
113
|
-
#
|
|
114
|
-
#
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
176
|
+
# The first model not in quarantine; when all are, wait for the nearest
|
|
177
|
+
# release if it fits into the budget. nil — it does not.
|
|
178
|
+
def ready_slot(stage, slots, visits)
|
|
179
|
+
ensure_time_left!(stage, bare_route(slots.first.candidate, slots.first.index), visits)
|
|
180
|
+
moment = now
|
|
181
|
+
ready = slots.find { |slot| state(slot.candidate).quarantine_left(moment) <= 0 }
|
|
182
|
+
return ready if ready
|
|
183
|
+
|
|
184
|
+
slot = slots.min_by { |item| state(item.candidate).quarantine_left(moment) }
|
|
185
|
+
wait = state(slot.candidate).quarantine_left(moment)
|
|
186
|
+
if wait > remaining_time
|
|
187
|
+
visits << Visit.new(route: bare_route(slot.candidate, slot.index),
|
|
188
|
+
note: format('quarantined for another %.0fs, over the time budget', wait))
|
|
189
|
+
return nil
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
@logger.warn(format('LLM %<stage>s: every model is quarantined, waiting %<wait>.0fs for %<model>s',
|
|
193
|
+
stage: stage, wait: wait, model: slot.candidate))
|
|
194
|
+
pause(wait)
|
|
195
|
+
slot
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# On :next_model hands over the current key: an overload is a property of
|
|
199
|
+
# the model, and the next model of the same provider continues from the
|
|
200
|
+
# same key instead of going back to the first one, whose quota may
|
|
201
|
+
# already be gone.
|
|
202
|
+
#
|
|
203
|
+
# Every visit either sends a request or excludes the model: otherwise the
|
|
204
|
+
# walk round the pool would never stop. A model whose keys are all out of
|
|
205
|
+
# daily quota is excluded until the end of the run.
|
|
206
|
+
def try_candidate(stage, slot, visits, carry, &request)
|
|
207
|
+
routes = candidate_routes(stage, slot, carry)
|
|
208
|
+
tried = false
|
|
209
|
+
routes.each do |route|
|
|
210
|
+
next if quota_exhausted?(stage, route, visits)
|
|
211
|
+
|
|
212
|
+
tried = true
|
|
213
|
+
log_switch(stage, route, visits)
|
|
214
|
+
status, response = try_route(stage, route, visits, &request)
|
|
215
|
+
return [:ok, remember(stage, route, response)] if status == :ok
|
|
216
|
+
return [:next_model, Carry.new(route.candidate.provider, route.key_index)] if status == :next_model
|
|
217
|
+
end
|
|
218
|
+
state(slot.candidate).exclude('daily quota exhausted on every key') unless tried
|
|
219
|
+
[:next_model, carry]
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# The keys start from the carried one (after an overload — the current
|
|
223
|
+
# key, after an answer — the one that answered), the rest follow: a quota
|
|
224
|
+
# is bound to "key + model", a failure on one model does not write the
|
|
225
|
+
# key off for another.
|
|
226
|
+
def candidate_routes(stage, slot, carry)
|
|
227
|
+
keys = @config.provider_api_keys(slot.candidate.provider)
|
|
120
228
|
routes = keys.each_with_index.map do |key, key_index|
|
|
121
|
-
Route.new(candidate: candidate, candidate_index: slot
|
|
229
|
+
Route.new(candidate: slot.candidate, candidate_index: slot.index, key: key,
|
|
122
230
|
key_index: key_index, key_count: keys.size)
|
|
123
231
|
end
|
|
124
|
-
routes.rotate(start_key(stage, slot,
|
|
232
|
+
routes.rotate(start_key(stage, slot, carry))
|
|
125
233
|
end
|
|
126
234
|
|
|
127
|
-
def start_key(stage, slot,
|
|
128
|
-
return
|
|
235
|
+
def start_key(stage, slot, carry)
|
|
236
|
+
return carry.key_index if carry && carry.provider == slot.candidate.provider
|
|
129
237
|
|
|
130
238
|
cursor_candidate, cursor_key = @cursor.fetch(stage, [0, 0])
|
|
131
|
-
cursor_candidate == slot
|
|
239
|
+
cursor_candidate == slot.index ? cursor_key : 0
|
|
132
240
|
end
|
|
133
241
|
|
|
134
|
-
|
|
135
|
-
|
|
242
|
+
# A visit to a route: requests until an answer, a retry or a refusal. try
|
|
243
|
+
# is the request number within this visit; the per-stage counter of the
|
|
244
|
+
# model is kept by ModelState.
|
|
245
|
+
def try_route(stage, route, visits)
|
|
246
|
+
model = state(route.candidate)
|
|
247
|
+
try = 0
|
|
136
248
|
loop do
|
|
137
|
-
|
|
138
|
-
|
|
249
|
+
return [:next_model, nil] unless requests_left?(stage, route)
|
|
250
|
+
|
|
251
|
+
try += 1
|
|
252
|
+
ensure_time_left!(stage, route, visits)
|
|
253
|
+
model.record_request(stage)
|
|
139
254
|
begin
|
|
140
255
|
return [:ok, yield(route, request_timeout)]
|
|
141
256
|
rescue StandardError => e
|
|
142
|
-
|
|
143
|
-
|
|
257
|
+
decision = handle_failure(stage, route, try, e, visits)
|
|
258
|
+
return [decision, nil] unless decision == :retry
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
end
|
|
144
262
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
263
|
+
# :retry — the pause has been waited out, retry; otherwise the decision for the route.
|
|
264
|
+
def handle_failure(stage, route, try, error, visits)
|
|
265
|
+
kind = LlmFailure.classify(error)
|
|
266
|
+
raise error if kind == :unhandled
|
|
148
267
|
|
|
149
|
-
|
|
150
|
-
|
|
268
|
+
@logger.warn("LLM #{stage} request failed (#{route}): #{error.class}: #{error.message}")
|
|
269
|
+
decision, delay = decide(kind, try, error)
|
|
270
|
+
raise ApiError, single_route_message(error) if decision == :fail
|
|
151
271
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
return [decision, nil]
|
|
157
|
-
end
|
|
272
|
+
if decision == :retry
|
|
273
|
+
return :retry if requests_left?(stage, route) && waited_before_retry?(stage, route, delay)
|
|
274
|
+
|
|
275
|
+
decision = give_up(kind)
|
|
158
276
|
end
|
|
277
|
+
record_failure(stage, route, kind, decision, error)
|
|
278
|
+
visits << Visit.new(route: route, kind: kind, tries: try, error: error)
|
|
279
|
+
decision
|
|
159
280
|
end
|
|
160
281
|
|
|
161
|
-
|
|
162
|
-
|
|
282
|
+
def requests_left?(stage, route)
|
|
283
|
+
return true if state(route.candidate).requests_left?(stage)
|
|
284
|
+
|
|
285
|
+
@logger.warn("LLM #{stage}: #{route.candidate} has used its #{MAX_ATTEMPTS_PER_MODEL} attempts")
|
|
286
|
+
false
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# :retry with a delay, :next_key, :next_model or :fail.
|
|
290
|
+
def decide(kind, try, error)
|
|
163
291
|
case kind
|
|
164
292
|
when :fatal then [:fail]
|
|
293
|
+
when :unavailable then [:next_model]
|
|
165
294
|
when :daily_quota then [:next_key]
|
|
166
|
-
when :rate_limit then
|
|
167
|
-
else
|
|
295
|
+
when :rate_limit then try == 1 ? [:retry, rate_limit_delay(error)] : [:next_key]
|
|
296
|
+
else try == 1 ? [:retry, short_delay] : [:next_model]
|
|
168
297
|
end
|
|
169
298
|
end
|
|
170
299
|
|
|
171
|
-
def
|
|
172
|
-
|
|
173
|
-
return [:retry, short_delay.merge(max_attempts: 2)] if attempt == 1
|
|
174
|
-
|
|
175
|
-
return [:next_model]
|
|
176
|
-
end
|
|
177
|
-
return [:next_model] if attempt > OVERLOADED_RETRY_DELAYS.size
|
|
178
|
-
|
|
179
|
-
[:retry, overloaded_delay(attempt)]
|
|
300
|
+
def give_up(kind)
|
|
301
|
+
kind == :rate_limit ? :next_key : :next_model
|
|
180
302
|
end
|
|
181
303
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
304
|
+
# A daily quota marks the key of the model, a missing model marks the
|
|
305
|
+
# model for the whole run, an overload or a timeout quarantines it, a
|
|
306
|
+
# per-minute limit quarantines it for the provider's hint (the next key
|
|
307
|
+
# is tried at once; the quarantine only affects the next visit).
|
|
308
|
+
def record_failure(stage, route, kind, decision, error)
|
|
309
|
+
model = state(route.candidate)
|
|
310
|
+
case kind
|
|
311
|
+
when :daily_quota then model.exhaust_key(route.key_index)
|
|
312
|
+
when :unavailable then model.exclude('model unavailable earlier in this run')
|
|
313
|
+
when :rate_limit then quarantine(stage, route, rate_limit_delay(error).seconds)
|
|
314
|
+
when :overloaded, :timeout then quarantine(stage, route, @config.overloaded_quarantine) if decision == :next_model
|
|
315
|
+
end
|
|
187
316
|
end
|
|
188
317
|
|
|
189
|
-
def
|
|
190
|
-
|
|
318
|
+
def quarantine(stage, route, seconds)
|
|
319
|
+
state(route.candidate).quarantine(now + seconds)
|
|
320
|
+
@logger.warn(format('LLM %<stage>s: %<model>s quarantined for %<seconds>.0fs',
|
|
321
|
+
stage: stage, model: route.candidate, seconds: seconds))
|
|
191
322
|
end
|
|
192
323
|
|
|
193
324
|
def short_delay
|
|
194
|
-
|
|
195
|
-
end
|
|
196
|
-
|
|
197
|
-
def overloaded_delay(attempt)
|
|
198
|
-
base_delay = OVERLOADED_RETRY_DELAYS.fetch(attempt - 1, OVERLOADED_RETRY_DELAYS.last)
|
|
199
|
-
multiplier = rand(OVERLOADED_RETRY_JITTER_RANGE)
|
|
200
|
-
{
|
|
201
|
-
delay: base_delay * multiplier,
|
|
202
|
-
source: format(' (overloaded backoff %<base>.0fs, multiplier %<multiplier>.2fx)',
|
|
203
|
-
base: base_delay, multiplier: multiplier),
|
|
204
|
-
max_attempts: OVERLOADED_RETRY_DELAYS.size + 1
|
|
205
|
-
}
|
|
325
|
+
Delay.new(SHORT_RETRY_DELAY * rand(SHORT_RETRY_JITTER_RANGE), '')
|
|
206
326
|
end
|
|
207
327
|
|
|
208
|
-
def rate_limit_delay(
|
|
328
|
+
def rate_limit_delay(error)
|
|
209
329
|
hint = LlmFailure.retry_after_seconds(error.message)
|
|
210
|
-
return
|
|
330
|
+
return Delay.new(RATE_LIMIT_BASE_DELAY + rand(RATE_LIMIT_JITTER_RANGE), '') unless hint
|
|
211
331
|
|
|
212
332
|
multiplier = rand(PROVIDER_RETRY_DELAY_MULTIPLIER_RANGE)
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
source: format(' (provider retry hint %<hint>.1fs, multiplier %<multiplier>.2fx)',
|
|
216
|
-
hint: hint, multiplier: multiplier)
|
|
217
|
-
}
|
|
333
|
+
Delay.new(hint * multiplier, format(' (provider retry hint %<hint>.1fs, multiplier %<multiplier>.2fx)',
|
|
334
|
+
hint: hint, multiplier: multiplier))
|
|
218
335
|
end
|
|
219
336
|
|
|
220
|
-
# false —
|
|
221
|
-
def waited_before_retry?(stage, route,
|
|
222
|
-
delay
|
|
223
|
-
if delay > remaining_time
|
|
337
|
+
# false — the pause does not fit into the time budget, no retry.
|
|
338
|
+
def waited_before_retry?(stage, route, delay)
|
|
339
|
+
if delay.seconds > remaining_time
|
|
224
340
|
@logger.warn(
|
|
225
341
|
format('LLM %<stage>s: no time budget left for a %<delay>.0fs pause (%<left>.0fs remaining, %<route>s)',
|
|
226
|
-
stage: stage, delay: delay, left: [remaining_time, 0].max, route: route)
|
|
342
|
+
stage: stage, delay: delay.seconds, left: [remaining_time, 0].max, route: route)
|
|
227
343
|
)
|
|
228
344
|
return false
|
|
229
345
|
end
|
|
230
346
|
|
|
231
347
|
@logger.warn(
|
|
232
|
-
format(RETRY_WAIT_LOG_FORMAT, stage: stage, delay: delay, source:
|
|
233
|
-
|
|
234
|
-
model: route.candidate.model)
|
|
348
|
+
format(RETRY_WAIT_LOG_FORMAT, stage: stage, delay: delay.seconds, source: delay.source,
|
|
349
|
+
next_request: state(route.candidate).sent(stage) + 1,
|
|
350
|
+
max_requests: MAX_ATTEMPTS_PER_MODEL, model: route.candidate.model)
|
|
235
351
|
)
|
|
236
|
-
started_at =
|
|
237
|
-
|
|
352
|
+
started_at = now
|
|
353
|
+
pause(delay.seconds)
|
|
238
354
|
@logger.info(format('LLM %<stage>s retry wait completed after %<waited>.1fs (model=%<model>s)',
|
|
239
|
-
stage: stage, waited:
|
|
355
|
+
stage: stage, waited: now - started_at, model: route.candidate.model))
|
|
240
356
|
true
|
|
241
357
|
end
|
|
242
358
|
|
|
243
|
-
def ensure_time_left!(stage, route,
|
|
359
|
+
def ensure_time_left!(stage, route, visits)
|
|
244
360
|
return if remaining_time >= 1
|
|
245
361
|
|
|
246
|
-
|
|
362
|
+
visits << Visit.new(route: route, note: 'no time budget left')
|
|
247
363
|
raise ApiError, "LLM time budget of #{@config.llm_time_budget}s is exhausted. " \
|
|
248
|
-
"#{exhausted_message(stage,
|
|
364
|
+
"#{exhausted_message(stage, visits)}"
|
|
249
365
|
end
|
|
250
366
|
|
|
251
367
|
def request_timeout
|
|
252
368
|
[@config.llm_timeout.to_f, remaining_time].min
|
|
253
369
|
end
|
|
254
370
|
|
|
255
|
-
def
|
|
256
|
-
return false
|
|
257
|
-
|
|
258
|
-
note = "skipped, request #{request_chars} chars over max_prompt_chars=#{candidate.max_prompt_chars}"
|
|
259
|
-
@logger.warn("LLM #{stage}: #{candidate} #{note}")
|
|
260
|
-
attempts << Attempt.new(route: Route.new(candidate: candidate, candidate_index: index, key_count: 1), note: note)
|
|
261
|
-
true
|
|
262
|
-
end
|
|
263
|
-
|
|
264
|
-
def quota_exhausted?(stage, route, attempts)
|
|
265
|
-
return false unless @exhausted.include?(exhausted_key(route))
|
|
371
|
+
def quota_exhausted?(stage, route, visits)
|
|
372
|
+
return false unless state(route.candidate).key_exhausted?(route.key_index)
|
|
266
373
|
|
|
267
374
|
@logger.info("LLM #{stage}: skipping #{route}, daily quota exhausted earlier in this run")
|
|
268
|
-
|
|
375
|
+
visits << Visit.new(route: route, note: 'daily quota exhausted earlier in this run')
|
|
269
376
|
true
|
|
270
377
|
end
|
|
271
378
|
|
|
379
|
+
# A model that answered is not overloaded, whichever key answered: a
|
|
380
|
+
# quarantine set through another key of the same model is lifted.
|
|
272
381
|
def remember(stage, route, response)
|
|
273
382
|
@cursor[stage] = [route.candidate_index, route.key_index]
|
|
274
383
|
@used[stage] = route
|
|
384
|
+
state(route.candidate).lift_quarantine
|
|
275
385
|
response
|
|
276
386
|
end
|
|
277
387
|
|
|
278
|
-
def
|
|
279
|
-
|
|
388
|
+
def log_switch(stage, route, visits)
|
|
389
|
+
return if visits.empty?
|
|
390
|
+
|
|
391
|
+
@logger.warn("LLM #{stage}: switching to #{route} after #{visits.last}")
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
# --- walking order ---
|
|
395
|
+
|
|
396
|
+
# The next request of the stage (the JSON repair, for instance) starts
|
|
397
|
+
# from the model that answered; the rest stay in reserve after it.
|
|
398
|
+
def ordered_chain(stage)
|
|
399
|
+
start_candidate, = @cursor.fetch(stage, [0, 0])
|
|
400
|
+
chain_for(stage).each_with_index.to_a.rotate(start_candidate)
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
# The critique chain depends on the model that answered in Generate:
|
|
404
|
+
# with a shared pool Critique does not go below it. Computed once per
|
|
405
|
+
# stage so that the repair and a restart walk the same chain.
|
|
406
|
+
def chain_for(stage)
|
|
407
|
+
@chains ||= {}
|
|
408
|
+
@chains[stage] ||= build_chain(stage)
|
|
280
409
|
end
|
|
281
410
|
|
|
282
|
-
|
|
283
|
-
|
|
411
|
+
# Warnings the plan raises while building a chain (an ignored
|
|
412
|
+
# critique.start, for instance) were not there when the CLI started —
|
|
413
|
+
# the router logs them.
|
|
414
|
+
def build_chain(stage)
|
|
415
|
+
known = @routing.warnings.size
|
|
416
|
+
chain = if stage == 'critique' && @used['generate']
|
|
417
|
+
@routing.critique_chain(after: @used['generate'].candidate)
|
|
418
|
+
else
|
|
419
|
+
@routing.chain(stage)
|
|
420
|
+
end
|
|
421
|
+
@routing.warnings.drop(known).each { |warning| @logger.warn(warning) }
|
|
422
|
+
chain
|
|
423
|
+
end
|
|
284
424
|
|
|
285
|
-
|
|
425
|
+
def pinned_chain(stage)
|
|
426
|
+
route = @used[stage]
|
|
427
|
+
raise ApiError, "LLM #{stage}: no model has answered yet, nothing to pin" unless route
|
|
428
|
+
|
|
429
|
+
[[route.candidate, route.candidate_index]]
|
|
286
430
|
end
|
|
287
431
|
|
|
288
|
-
|
|
289
|
-
single = attempts.size == 1 && attempts.first.error
|
|
290
|
-
return single_route_message(attempts.first.error) if single
|
|
432
|
+
# --- error messages ---
|
|
291
433
|
|
|
292
|
-
|
|
434
|
+
def exhausted_error(stage, visits, pinned)
|
|
435
|
+
(pinned ? RouteExhaustedError : ApiError).new(exhausted_message(stage, visits))
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
# One error on one route — a short message about it; otherwise the list
|
|
439
|
+
# of routes with reasons.
|
|
440
|
+
def exhausted_message(stage, visits)
|
|
441
|
+
return single_route_message(visits.first.error) if visits.size == 1 && visits.first.error
|
|
442
|
+
|
|
443
|
+
"LLM #{stage} request failed on every configured route: #{visits.join('; ')}. #{SWITCH_HINT}"
|
|
293
444
|
end
|
|
294
445
|
|
|
295
446
|
def single_route_message(error)
|
|
@@ -300,6 +451,8 @@ module Aireview
|
|
|
300
451
|
"LLM service is temporarily unavailable or overloaded: #{error.message}. #{SWITCH_HINT}"
|
|
301
452
|
when :rate_limit, :daily_quota
|
|
302
453
|
"LLM rate limit exceeded: #{error.message}. #{SWITCH_HINT}"
|
|
454
|
+
when :unavailable
|
|
455
|
+
"LLM model is unavailable: #{error.message}. #{SWITCH_HINT}"
|
|
303
456
|
else
|
|
304
457
|
fatal_message(error)
|
|
305
458
|
end
|
|
@@ -313,8 +466,12 @@ module Aireview
|
|
|
313
466
|
end
|
|
314
467
|
end
|
|
315
468
|
|
|
316
|
-
def
|
|
317
|
-
|
|
469
|
+
def now
|
|
470
|
+
@clock.call
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
def pause(seconds)
|
|
474
|
+
@sleeper.call(seconds)
|
|
318
475
|
end
|
|
319
476
|
end
|
|
320
477
|
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Aireview
|
|
4
|
+
KNOWN_PROVIDERS = %w[gemini ollama].freeze
|
|
5
|
+
|
|
6
|
+
# A model in a stage chain: provider, name and request size limit.
|
|
7
|
+
# Compared by provider and name — the limit depends on the stage.
|
|
8
|
+
ModelCandidate = Struct.new(:provider, :model, :max_prompt_chars, keyword_init: true) do
|
|
9
|
+
def to_s
|
|
10
|
+
"#{provider}/#{model}"
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def same_model?(other)
|
|
14
|
+
to_s == other.to_s
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# A "provider/name" string or a bare name (the provider is separated by
|
|
18
|
+
# a slash because Ollama tags contain a colon), or a hash with model.
|
|
19
|
+
def self.parse_item(item)
|
|
20
|
+
return item unless item.is_a?(String)
|
|
21
|
+
|
|
22
|
+
provider, model = item.split('/', 2)
|
|
23
|
+
return {'provider' => provider, 'model' => model} if model && KNOWN_PROVIDERS.include?(provider)
|
|
24
|
+
|
|
25
|
+
{'model' => item}
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|