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.
@@ -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
- # квота свойство «ключ + модель», поэтому по 503 меняется модель, по
9
- # квоте ключ. Всё вместе ограничено общим бюджетом времени прогона.
10
- class LlmRouter
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
- Attempt = Struct.new(:route, :kind, :attempts, :error, :note, keyword_init: true) do
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 #{attempts} attempt(s)"}"
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
- '(attempt %<next_attempt>d/%<max_attempts>d, model=%<model>s)'
59
+ '(request %<next_request>d/%<max_requests>d of the model in this stage, model=%<model>s)'
47
60
 
48
- def initialize(config:, logger:)
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
- @exhausted = Set.new
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
- # другая модель или ApiError, когда маршруты кончились.
60
- def call(stage:, request_chars:, &request)
61
- @deadline ||= monotonic_time + @config.llm_time_budget
62
- attempts = []
63
- chain = ordered_chain(stage).reject do |candidate, index|
64
- oversized?(stage, candidate, index, request_chars, attempts)
65
- end
66
- carried = nil
67
- chain.each_with_index do |(candidate, index), position|
68
- slot = {candidate: candidate, index: index, last_model: position == chain.size - 1}
69
- status, value = try_candidate(stage, slot, attempts, carried, &request)
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
- carried = value
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 - monotonic_time : @config.llm_time_budget.to_f
134
+ @deadline ? @deadline - now : @config.llm_time_budget.to_f
85
135
  end
86
136
 
87
137
  private
88
138
 
89
- # При :next_model отдаёт текущий ключ: перегрузка — свойство модели, и
90
- # следующая модель того же провайдера продолжает с того же ключа, а не
91
- # возвращается к первому, у которого квота могла уже кончиться.
92
- def try_candidate(stage, slot, attempts, carried, &request)
93
- routes = candidate_routes(stage, slot, carried)
94
- routes.each_with_index do |route, position|
95
- next if quota_exhausted?(stage, route, attempts)
96
-
97
- log_switch(stage, route, attempts)
98
- status, response = try_route(stage, route, attempts,
99
- {last_model: slot[:last_model], last_key: position == routes.size - 1}, &request)
100
- return [:ok, remember(stage, route, response)] if status == :ok
101
- return [:next_model, {provider: route.candidate.provider, key_index: route.key_index}] if status == :next_model
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
- # Следующий запрос стадии (например, починка JSON) начинается с модели,
107
- # которая ответила; остальные остаются в резерве после неё.
108
- def ordered_chain(stage)
109
- start_candidate, = @cursor.fetch(stage, [0, 0])
110
- @config.stage_chain(stage).each_with_index.to_a.rotate(start_candidate)
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
- def candidate_routes(stage, slot, carried)
118
- candidate = slot[:candidate]
119
- keys = @config.provider_api_keys(candidate.provider)
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[:index], key: key,
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, carried))
232
+ routes.rotate(start_key(stage, slot, carry))
125
233
  end
126
234
 
127
- def start_key(stage, slot, carried)
128
- return carried[:key_index] if carried && carried[:provider] == slot[:candidate].provider
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[:index] ? cursor_key : 0
239
+ cursor_candidate == slot.index ? cursor_key : 0
132
240
  end
133
241
 
134
- def try_route(stage, route, attempts, position)
135
- attempt = 0
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
- attempt += 1
138
- ensure_time_left!(stage, route, attempts)
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
- kind = LlmFailure.classify(e)
143
- raise if kind == :unhandled
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
- @logger.warn("LLM #{stage} request failed (#{route}): #{e.class}: #{e.message}")
146
- decision, delay = decide(kind, attempt, position, e)
147
- raise ApiError, single_route_message(e) if decision == :fail
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
- if decision == :retry
150
- next if waited_before_retry?(stage, route, delay, attempt)
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
- decision = give_up(kind)
153
- end
154
- @exhausted << exhausted_key(route) if kind == :daily_quota
155
- attempts << Attempt.new(route: route, kind: kind, attempts: attempt, error: e)
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
- # :retry с паузой, :next_key, :next_model или :fail.
162
- def decide(kind, attempt, position, error)
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 rate_limit_decision(attempt, position, error)
167
- else overloaded_decision(attempt, position)
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 overloaded_decision(attempt, position)
172
- unless position[:last_model]
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
- def rate_limit_decision(attempt, position, error)
183
- max_retries = position[:last_model] && position[:last_key] ? MAX_RATE_LIMIT_RETRIES : 1
184
- return [:next_key] if attempt > max_retries
185
-
186
- [:retry, rate_limit_delay(attempt, error).merge(max_attempts: max_retries + 1)]
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 give_up(kind)
190
- %i[overloaded timeout].include?(kind) ? :next_model : :next_key
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
- {delay: SHORT_RETRY_DELAY * rand(SHORT_RETRY_JITTER_RANGE)}
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(attempt, error)
328
+ def rate_limit_delay(error)
209
329
  hint = LlmFailure.retry_after_seconds(error.message)
210
- return {delay: (RATE_LIMIT_BASE_DELAY * (2**(attempt - 1))) + rand(RATE_LIMIT_JITTER_RANGE)} unless hint
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
- delay: hint * multiplier,
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, retry_delay, attempt)
222
- delay = retry_delay[: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: retry_delay[:source].to_s,
233
- next_attempt: attempt + 1, max_attempts: retry_delay[:max_attempts] || 2,
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 = monotonic_time
237
- sleep(delay)
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: monotonic_time - started_at, model: route.candidate.model))
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, attempts)
359
+ def ensure_time_left!(stage, route, visits)
244
360
  return if remaining_time >= 1
245
361
 
246
- attempts << Attempt.new(route: route, note: 'no time budget left')
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, attempts)}"
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 oversized?(stage, candidate, index, request_chars, attempts)
256
- return false if request_chars <= candidate.max_prompt_chars
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
- attempts << Attempt.new(route: route, note: 'daily quota exhausted earlier in this run')
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 exhausted_key(route)
279
- [route.candidate.provider, route.key_index, route.candidate.model]
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
- def log_switch(stage, route, attempts)
283
- return if attempts.empty?
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
- @logger.warn("LLM #{stage}: switching to #{route} after #{attempts.last}")
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
- def exhausted_message(stage, attempts)
289
- single = attempts.size == 1 && attempts.first.error
290
- return single_route_message(attempts.first.error) if single
432
+ # --- error messages ---
291
433
 
292
- "LLM #{stage} request failed on every configured route: #{attempts.join('; ')}. #{SWITCH_HINT}"
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 monotonic_time
317
- Process.clock_gettime(Process::CLOCK_MONOTONIC)
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