lex-llm-gemini 0.3.13 → 0.4.4

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,22 +1,886 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'concurrent'
4
+ require 'digest'
5
+ require 'json'
6
+ require 'uri'
7
+ require 'faraday'
8
+
3
9
  begin
4
10
  require 'legion/extensions/actors/every'
5
- rescue LoadError => e
6
- warn(e.message) if $VERBOSE
11
+ rescue LoadError
12
+ nil
13
+ end
14
+
15
+ unless defined?(Legion::Extensions::Actors::Every)
16
+ raise LoadError, 'LegionIO actor runtime is required for Gemini discovery'
7
17
  end
8
18
 
9
- return unless defined?(Legion::Extensions::Actors::Every)
19
+ require 'legion/extensions/llm/gemini/provider'
20
+ require 'legion/extensions/llm/inventory/publisher'
21
+ require 'legion/extensions/llm/inventory/scoped_refresher'
22
+ require 'legion/extensions/llm/inventory/identity'
23
+ require 'legion/extensions/llm/inventory/records'
24
+ require 'legion/extensions/llm/inventory/evidence'
25
+ require 'legion/extensions/llm/inventory/probe_coordinator'
26
+ require 'legion/extensions/llm/routing/provider_outcome'
27
+ require 'legion/extensions/llm/taxonomies'
28
+ require 'legion/extensions/llm/capabilities'
10
29
 
11
30
  module Legion
12
31
  module Extensions
13
32
  module Llm
14
33
  module Gemini
15
34
  module Actor
16
- class DiscoveryRefresh < Legion::Extensions::Actors::Every # rubocop:disable Style/Documentation
17
- include Legion::Logging::Helper
35
+ # ── Evidence building helpers ────────────────────────────────────────
36
+ module EvidenceBuilder
37
+ private
38
+
39
+ def extract_generation_methods(model_data:)
40
+ Array(
41
+ model_data[:supportedGenerationMethods] ||
42
+ model_data[:supported_generation_methods] ||
43
+ model_data['supportedGenerationMethods'] ||
44
+ model_data['supported_generation_methods']
45
+ )
46
+ end
47
+
48
+ def build_operation_evidence(generation_methods:)
49
+ now = Time.now.freeze
50
+ chat_status = resolve_operation_status(generation_methods: generation_methods,
51
+ action: 'generateContent')
52
+ stream_status = resolve_operation_status(generation_methods: generation_methods,
53
+ action: 'streamGenerateContent')
54
+ embed_status = resolve_operation_status(generation_methods: generation_methods, action: 'embedContent')
55
+ {
56
+ chat: op_evidence(operation: :chat, status: chat_status, observed_at: now),
57
+ stream_chat: op_evidence(operation: :stream_chat, status: stream_status, observed_at: now),
58
+ embed: op_evidence(operation: :embed, status: embed_status, observed_at: now),
59
+ image: op_evidence(operation: :image, status: :unsupported, observed_at: now),
60
+ transcribe: op_evidence(operation: :transcribe, status: :unsupported, observed_at: now),
61
+ translate: op_evidence(operation: :translate, status: :unsupported, observed_at: now),
62
+ speak: op_evidence(operation: :speak, status: :unsupported, observed_at: now),
63
+ moderate: op_evidence(operation: :moderate, status: :unsupported, observed_at: now),
64
+ count_tokens: op_evidence(operation: :count_tokens, status: :unknown, observed_at: now)
65
+ }
66
+ end
67
+
68
+ def resolve_operation_status(generation_methods:, action:)
69
+ return :unknown if generation_methods.empty?
70
+
71
+ generation_methods.include?(action) ? :supported : :unsupported
72
+ end
73
+
74
+ def op_evidence(operation:, status:, observed_at:)
75
+ source = status == :unknown ? :default_false : :provider_catalog
76
+ Legion::Extensions::Llm::Inventory::OperationEvidence.new(
77
+ operation: operation, status: status, source: source, observed_at: observed_at
78
+ )
79
+ end
80
+
81
+ def build_capability_evidence(generation_methods:, **)
82
+ {
83
+ completion: cap_from_method(capability: :completion, methods: generation_methods,
84
+ action: 'generateContent'),
85
+ streaming: cap_from_method(capability: :streaming, methods: generation_methods,
86
+ action: 'streamGenerateContent'),
87
+ embedding: cap_from_method(capability: :embedding, methods: generation_methods,
88
+ action: 'embedContent'),
89
+ tools: cap_evidence(capability: :tools, status: :unknown, source: :default_false),
90
+ thinking: cap_evidence(capability: :thinking, status: :unknown, source: :default_false),
91
+ vision: cap_evidence(capability: :vision, status: :unknown, source: :default_false)
92
+ }
93
+ end
94
+
95
+ def cap_from_method(capability:, methods:, action:)
96
+ present = methods.include?(action)
97
+ cap_evidence(
98
+ capability: capability,
99
+ status: present ? :supported : :unknown,
100
+ source: present ? :provider_catalog : :default_false
101
+ )
102
+ end
103
+
104
+ def cap_evidence(capability:, status:, source:)
105
+ Legion::Extensions::Llm::Inventory::CapabilityEvidence.new(
106
+ capability: capability, status: status, source: source, observed_at: Time.now.freeze
107
+ )
108
+ end
109
+ end
110
+
111
+ # ── Value evidence helpers ────────────────────────────────────────────
112
+ module ValueEvidenceBuilder
113
+ private
114
+
115
+ def build_context_evidence(model_data:)
116
+ ctx = model_data[:inputTokenLimit] || model_data['inputTokenLimit'] || model_data[:input_token_limit]
117
+ if ctx.is_a?(Integer) && ctx.positive?
118
+ Legion::Extensions::Llm::Inventory::ValueEvidence.new(status: :known, value: ctx,
119
+ source: :provider_catalog)
120
+ else
121
+ Legion::Extensions::Llm::Inventory::ValueEvidence.new(status: :unknown, source: :absent)
122
+ end
123
+ end
124
+
125
+ def build_max_output_evidence(model_data:)
126
+ max_out = model_data[:outputTokenLimit] || model_data['outputTokenLimit'] ||
127
+ model_data[:output_token_limit]
128
+ if max_out.is_a?(Integer) && max_out.positive?
129
+ Legion::Extensions::Llm::Inventory::ValueEvidence.new(status: :known, value: max_out,
130
+ source: :provider_catalog)
131
+ else
132
+ Legion::Extensions::Llm::Inventory::ValueEvidence.new(status: :unknown, source: :absent)
133
+ end
134
+ end
135
+
136
+ def build_embedding_dimensions_evidence(model_data:, generation_methods:)
137
+ unless generation_methods.include?('embedContent')
138
+ return Legion::Extensions::Llm::Inventory::ValueEvidence.new(status: :unknown, source: :absent)
139
+ end
140
+
141
+ dims = extract_valid_dimensions(model_data: model_data)
142
+ if dims
143
+ Legion::Extensions::Llm::Inventory::ValueEvidence.new(status: :known, value: dims,
144
+ source: :provider_catalog)
145
+ else
146
+ Legion::Extensions::Llm::Inventory::ValueEvidence.new(status: :unknown, source: :absent)
147
+ end
148
+ end
149
+
150
+ def extract_valid_dimensions(model_data:)
151
+ dims = model_data[:embedding_dimensions] || model_data['embeddingDimensions']
152
+ return nil unless dims.is_a?(Array) && !dims.empty?
153
+ return nil unless dims.all? { |d| d.is_a?(Integer) && d.positive? }
154
+
155
+ dims.uniq.sort
156
+ end
157
+
158
+ def build_model_revision_evidence(model_data:)
159
+ revision = model_data[:version] || model_data['version']
160
+ if revision.is_a?(String) && !revision.strip.empty?
161
+ Legion::Extensions::Llm::Inventory::ValueEvidence.new(
162
+ status: :known, value: revision.strip, source: :provider_catalog
163
+ )
164
+ else
165
+ Legion::Extensions::Llm::Inventory::ValueEvidence.new(status: :unknown, source: :absent)
166
+ end
167
+ end
168
+
169
+ def build_tokenizer_evidence
170
+ Legion::Extensions::Llm::Inventory::ValueEvidence.new(status: :unknown, source: :absent)
171
+ end
172
+ end
173
+
174
+ # ── Model discovery helpers ──────────────────────────────────────────
175
+ module ModelDiscovery
176
+ private
177
+
178
+ # Only transport and body-parse failures yield "no offerings".
179
+ # Programming errors (NameError/NoMethodError/ArgumentError) must
180
+ # propagate to the caller's loud log path — rescuing them here
181
+ # would publish an activated instance with ZERO offerings
182
+ # (invisible to the router) while looking healthy.
183
+ def discover_offerings_for_instance(instance_cfg:, instance_key:)
184
+ models = fetch_models(instance_cfg: instance_cfg)
185
+ models.filter_map do |model_data|
186
+ model_id = extract_model_id(model_data: model_data)
187
+ next if model_id.empty?
188
+
189
+ build_offering_draft(
190
+ model_id: model_id, model_data: model_data,
191
+ instance_cfg: instance_cfg, instance_key: instance_key
192
+ )
193
+ end
194
+ rescue Faraday::Error, Legion::JSON::ParseError => e
195
+ handle_exception(e, level: :warn, operation: 'gemini.actor.discover_offerings')
196
+ []
197
+ end
198
+
199
+ def fetch_models(instance_cfg:)
200
+ base_url = resolve_api_base(instance_cfg: instance_cfg)
201
+ conn = build_api_connection(base_url: base_url, instance_cfg: instance_cfg)
202
+ response = conn.get('models')
203
+ body = Legion::JSON.load(response.body)
204
+ Array(body[:models])
205
+ end
206
+
207
+ def extract_model_id(model_data:)
208
+ name = model_data[:name] || model_data['name'] || ''
209
+ name.to_s.delete_prefix('models/')
210
+ end
211
+
212
+ def build_offering_draft(model_id:, model_data:, instance_cfg:, instance_key:)
213
+ tier = instance_cfg[:tier] || :frontier
214
+ generation_methods = extract_generation_methods(model_data: model_data)
215
+
216
+ Legion::Extensions::Llm::Inventory::OfferingDraft.new(
217
+ provider_native_key: model_id, model: model_id, tier: tier,
218
+ operation_evidence: build_operation_evidence(generation_methods: generation_methods),
219
+ capability_evidence: build_capability_evidence(generation_methods: generation_methods),
220
+ context_evidence: build_context_evidence(model_data: model_data),
221
+ max_output_evidence: build_max_output_evidence(model_data: model_data),
222
+ embedding_dimensions_evidence: build_embedding_dimensions_evidence(
223
+ model_data: model_data, generation_methods: generation_methods
224
+ ),
225
+ model_revision_evidence: build_model_revision_evidence(model_data: model_data),
226
+ tokenizer_evidence: build_tokenizer_evidence,
227
+ quota_domains: {},
228
+ metadata: build_offering_metadata(model_data: model_data, instance_key: instance_key),
229
+ publication_source: :provider_catalog
230
+ )
231
+ end
232
+
233
+ def build_offering_metadata(model_data:, instance_key:)
234
+ meta = { raw_model: extract_model_id(model_data: model_data) }
235
+ meta[:display_name] = model_data[:displayName].to_s if model_data[:displayName]
236
+ meta[:description] = model_data[:description].to_s[0, 200] if model_data[:description]
237
+ meta[:instance_id] = instance_key.instance_id
238
+ meta
239
+ end
240
+ end
241
+
242
+ # ── Instance configuration helpers ───────────────────────────────────
243
+ module ConfigResolver
244
+ private
245
+
246
+ def configured_instances
247
+ instances = {}
248
+ cfg_instances = settings[:instances]
249
+ if cfg_instances.is_a?(Hash)
250
+ cfg_instances.each do |name, config|
251
+ normalized = claimable_instance_config(config: config)
252
+ instances[name.to_sym] = normalized if normalized
253
+ end
254
+ end
255
+ if instances.empty?
256
+ auto_instance = build_auto_instance
257
+ instances[:default_instance] = auto_instance if auto_instance
258
+ end
259
+ instances
260
+ end
261
+
262
+ # Returns the normalized config when the entry is claimable, else
263
+ # nil: the always-present synthetic instances.default template is
264
+ # loudly skipped while unmodified, and every other entry needs a
265
+ # resolvable API key.
266
+ def claimable_instance_config(config:)
267
+ normalized = normalize_instance_config(config: config)
268
+
269
+ return unless resolvable_api_key?(normalized[:gemini_api_key])
270
+
271
+ normalized
272
+ end
273
+
274
+ # The synthetic instances.default section — ProviderSettings.build
275
+ # always nests the extension's own instance defaults (endpoint,
276
+ # discovery_interval, the env://GEMINI_API_KEY placeholder
277
+ # credential, fleet/limits blocks) there. It is "configured" only
278
+ # when the operator changed the entry: while it is still the
279
+ # unmodified template (placeholder credential included) it is a
280
+ # phantom the provider layer must never claim. A 'default' entry
281
+ # the operator modified (a real API key, a different endpoint) is
282
+ # a plain instance label — v2 accepted 'default' as an ordinary
283
+ # name — and passes through to the claim path.
284
+ def unconfigured_default?(name:, config:)
285
+ name.to_s == 'default' && deep_symbolize(config) == synthetic_default_instance
286
+ end
287
+
288
+ # The nested template, straight from the extension's registered
289
+ # defaults — compared by value against the live template, never
290
+ # against a hardcoded literal.
291
+ def synthetic_default_instance
292
+ @synthetic_default_instance ||=
293
+ Legion::Extensions::Llm::Gemini.default_settings.dig(:instances, :default)
294
+ end
295
+
296
+ # Settings entries arrive with string or symbol keys (YAML vs
297
+ # JSON vs the nested template); canonicalize before comparing.
298
+ def deep_symbolize(value)
299
+ case value
300
+ when Hash
301
+ value.to_h { |key, inner| [key.respond_to?(:to_sym) ? key.to_sym : key, deep_symbolize(inner)] }
302
+ when Array
303
+ value.map { |inner| deep_symbolize(inner) }
304
+ else
305
+ value
306
+ end
307
+ end
308
+
309
+ def build_auto_instance
310
+ api_key = settings.dig(:credentials, :api_key)
311
+ api_key = resolve_env_credential(api_key) if env_credential?(api_key)
312
+ return nil unless resolvable_api_key?(api_key)
313
+
314
+ {
315
+ gemini_api_base: settings[:endpoint],
316
+ gemini_api_key: api_key,
317
+ tier: settings[:tier]
318
+ }
319
+ end
320
+
321
+ def resolvable_api_key?(api_key)
322
+ api_key.is_a?(String) && !api_key.strip.empty?
323
+ end
324
+
325
+ def env_credential?(value)
326
+ value.is_a?(String) && value.start_with?('env://')
327
+ end
328
+
329
+ def resolve_env_credential(value)
330
+ ENV.fetch(value.delete_prefix('env://'), nil)
331
+ end
332
+
333
+ def normalize_instance_config(config:)
334
+ normalized = config.to_h.transform_keys(&:to_sym)
335
+ resolve_instance_api_base(normalized: normalized)
336
+ resolve_instance_credentials(normalized: normalized)
337
+ normalized[:tier] ||= :frontier
338
+ normalized
339
+ end
340
+
341
+ def resolve_instance_api_base(normalized:)
342
+ normalized[:gemini_api_base] ||= normalized.delete(:base_url)
343
+ normalized[:gemini_api_base] ||= normalized.delete(:api_base)
344
+ normalized[:gemini_api_base] ||= normalized.delete(:endpoint)
345
+ normalized[:gemini_api_base] ||= 'https://generativelanguage.googleapis.com/v1beta'
346
+ end
347
+
348
+ # Resolves env:// credential references in the instances.* path so an
349
+ # env-only deployment claims the real key instead of the literal
350
+ # 'env://GEMINI_API_KEY' placeholder (which would 4xx and pin the
351
+ # instance in :initializing with a placeholder-fingerprinted id).
352
+ def resolve_instance_credentials(normalized:)
353
+ normalized[:gemini_api_key] ||= normalized.delete(:api_key)
354
+ creds = normalized.delete(:credentials)
355
+ if creds.is_a?(Hash)
356
+ creds = creds.transform_keys(&:to_sym)
357
+ normalized[:gemini_api_key] ||= creds[:api_key]
358
+ end
359
+ return unless env_credential?(normalized[:gemini_api_key])
360
+
361
+ normalized[:gemini_api_key] = resolve_env_credential(normalized[:gemini_api_key])
362
+ end
363
+ end
364
+
365
+ # ── HTTP connection helpers ──────────────────────────────────────────
366
+ module HttpClient
367
+ private
368
+
369
+ def resolve_api_base(instance_cfg:)
370
+ (instance_cfg[:gemini_api_base] || instance_cfg[:endpoint] ||
371
+ 'https://generativelanguage.googleapis.com/v1beta').to_s
372
+ end
373
+
374
+ def build_api_connection(base_url:, instance_cfg:)
375
+ Faraday.new(url: base_url) do |f|
376
+ f.options.timeout = 15
377
+ f.options.open_timeout = 5
378
+ f.headers['Accept'] = 'application/json'
379
+ apply_auth_header(faraday: f, instance_cfg: instance_cfg)
380
+ f.adapter Faraday.default_adapter
381
+ end
382
+ end
383
+
384
+ def apply_auth_header(faraday:, instance_cfg:)
385
+ api_key = instance_cfg[:gemini_api_key] || instance_cfg[:api_key] ||
386
+ instance_cfg.dig(:credentials, :api_key)
387
+ return unless api_key.is_a?(String) && !api_key.strip.empty?
388
+
389
+ faraday.headers['x-goog-api-key'] = api_key
390
+ end
391
+
392
+ # SECONDARY physical id (InstanceKey.physical_id): the derived
393
+ # host:port/ak fingerprint, kept for dedup and diagnostics only.
394
+ # Instance IDENTIFICATION is the operator's config name — see
395
+ # claim_and_activate_instance. Never the identity itself.
396
+ def derive_physical_id(instance_cfg:)
397
+ base_url = instance_cfg[:gemini_api_base] || instance_cfg[:endpoint] ||
398
+ 'https://generativelanguage.googleapis.com/v1beta'
399
+ host_port = extract_host_port(url: base_url)
400
+ api_key = instance_cfg[:gemini_api_key] || instance_cfg[:api_key] ||
401
+ instance_cfg.dig(:credentials, :api_key)
402
+
403
+ return host_port unless api_key.is_a?(String) && !api_key.strip.empty?
404
+
405
+ fingerprint = ::Digest::SHA256.hexdigest(api_key)[0, 8]
406
+ "#{host_port}/ak:#{fingerprint}"
407
+ end
408
+
409
+ def extract_host_port(url:)
410
+ uri = URI.parse(url.to_s)
411
+ host = uri.host || 'generativelanguage.googleapis.com'
412
+ "#{host}:#{uri.port}"
413
+ rescue URI::InvalidURIError => e
414
+ handle_exception(e, level: :warn, operation: 'gemini.actor.extract_host_port', url: url.to_s)
415
+ 'unknown:0'
416
+ end
417
+ end
418
+
419
+ # ── Readiness probe helpers ──────────────────────────────────────────
420
+ module ProbeRunner
421
+ private
422
+
423
+ def run_cadence_probe(instance_id:, state:)
424
+ coordinator = state[:probe_coordinator]
425
+ return unless coordinator.begin_probe
426
+
427
+ run_instance_probe(instance_id: instance_id, state: state, coordinator: coordinator)
428
+ sync_display_health(state: state)
429
+ rescue StandardError => e
430
+ finish_probe_on_error(coordinator: coordinator)
431
+ handle_exception(e, level: :warn, operation: 'gemini.actor.cadence_probe', instance_id: instance_id)
432
+ end
433
+
434
+ def handle_reactive_probe(instance_id:, request:)
435
+ state = @instance_states[instance_id]
436
+ return unless state
437
+
438
+ coordinator = state[:probe_coordinator]
439
+ return unless coordinator.begin_probe(request: request)
440
+
441
+ run_instance_probe(instance_id: instance_id, state: state, coordinator: coordinator, request: request)
442
+ sync_display_health(state: state)
443
+ rescue StandardError => e
444
+ finish_probe_on_error(coordinator: coordinator, request: request)
445
+ handle_exception(e, level: :warn, operation: 'gemini.actor.reactive_probe', instance_id: instance_id)
446
+ end
447
+
448
+ # Shared probe body: starts the publisher probe, checks health,
449
+ # finishes the coordinator probe (coalesced probes pass their
450
+ # request token), and reports the outcome.
451
+ def run_instance_probe(instance_id:, state:, coordinator:, request: nil)
452
+ probe_token = publisher.readiness_probe_started(
453
+ instance_id: instance_id, publisher_token: state[:publisher_token],
454
+ physical_id: state[:instance_key].physical_id
455
+ )
456
+ readiness = check_health(instance_cfg: state[:instance_cfg])
457
+ if request
458
+ coordinator.finish_probe(request: request)
459
+ else
460
+ coordinator.finish_probe
461
+ end
462
+ report_probe_result(instance_id: instance_id, state: state,
463
+ probe_token: probe_token, readiness: readiness)
464
+ end
465
+
466
+ # Best-effort probe release on the error path — a failure to
467
+ # release is logged, never raised over the original error.
468
+ def finish_probe_on_error(coordinator:, request: nil)
469
+ if request
470
+ coordinator&.finish_probe(request: request)
471
+ else
472
+ coordinator&.finish_probe
473
+ end
474
+ rescue StandardError => e
475
+ handle_exception(e, level: :warn, operation: 'gemini.actor.probe.finish_probe')
476
+ end
477
+
478
+ def report_probe_result(instance_id:, state:, probe_token:, readiness:)
479
+ if readiness.ready?
480
+ publisher.readiness_succeeded(
481
+ instance_id: instance_id, physical_id: state[:instance_key].physical_id, probe_token: probe_token
482
+ )
483
+ else
484
+ publisher.readiness_failed(
485
+ instance_id: instance_id, physical_id: state[:instance_key].physical_id,
486
+ probe_token: probe_token, reason: readiness.reason
487
+ )
488
+ end
489
+ end
490
+
491
+ def build_probe_enqueue(instance_id:)
492
+ proc do |request:|
493
+ handle_reactive_probe(instance_id: instance_id, request: request)
494
+ true
495
+ rescue StandardError => e
496
+ handle_exception(e, level: :warn, operation: 'gemini.actor.probe_enqueue', instance_id: instance_id)
497
+ false
498
+ end
499
+ end
500
+ end
501
+
502
+ # ── Health check helpers ─────────────────────────────────────────────
503
+ module HealthChecker
504
+ private
505
+
506
+ def check_health(instance_cfg:)
507
+ base_url = resolve_api_base(instance_cfg: instance_cfg)
508
+ conn = build_api_connection(base_url: base_url, instance_cfg: instance_cfg)
509
+ response = conn.get('models', { pageSize: 1 })
510
+ build_readiness_from_response(response: response, base_url: base_url)
511
+ rescue Faraday::ConnectionFailed => e
512
+ handle_exception(e, level: :warn, operation: 'gemini.actor.check_health.connection',
513
+ base_url: base_url)
514
+ readiness_failure(reason: "Gemini models API connection failed: #{e.message}", error: e)
515
+ rescue StandardError => e
516
+ handle_exception(e, level: :warn, operation: 'gemini.actor.check_health', base_url: base_url)
517
+ readiness_failure(reason: "Gemini models API error: #{e.message}", error: e)
518
+ end
519
+
520
+ def build_readiness_from_response(response:, base_url:)
521
+ Legion::Extensions::Llm::Inventory::ReadinessResult.new(
522
+ ready: response.status == 200,
523
+ reason: "Gemini models API returned #{response.status}",
524
+ metadata: { status: response.status, base_url: base_url }
525
+ )
526
+ end
527
+
528
+ def readiness_failure(reason:, error:)
529
+ Legion::Extensions::Llm::Inventory::ReadinessResult.new(
530
+ ready: false,
531
+ reason: reason,
532
+ metadata: { error_class: error.class.name }
533
+ )
534
+ end
535
+ end
18
536
 
19
- REFRESH_INTERVAL = 1800
537
+ # ── Instance lifecycle helpers ───────────────────────────────────────
538
+ # ── Offering change comparison helpers ───────────────────────────────
539
+ module OfferingComparison
540
+ private
541
+
542
+ # Compare on identity and evidence status, not Data#==: every draft
543
+ # embeds a fresh Time.now observed_at, so Data equality is false
544
+ # across ticks even when the model set and capabilities are
545
+ # unchanged (replace churn on every tick).
546
+ def offerings_changed?(previous:, current:)
547
+ current.map { |draft| offering_signature(draft) } !=
548
+ previous.map { |draft| offering_signature(draft) }
549
+ end
550
+
551
+ def offering_signature(draft)
552
+ [
553
+ draft.provider_native_key,
554
+ draft.model,
555
+ draft.tier,
556
+ operation_signature(draft),
557
+ capability_signature(draft),
558
+ value_signature(draft)
559
+ ]
560
+ end
561
+
562
+ def operation_signature(draft)
563
+ draft.operation_evidence.values.map do |evidence|
564
+ [evidence.operation, evidence.status, evidence.source]
565
+ end.sort
566
+ end
567
+
568
+ def capability_signature(draft)
569
+ draft.capability_evidence.values.map do |evidence|
570
+ [evidence.capability, evidence.status, evidence.source]
571
+ end.sort
572
+ end
573
+
574
+ def value_signature(draft)
575
+ [
576
+ value_pair(draft.context_evidence),
577
+ value_pair(draft.max_output_evidence),
578
+ value_pair(draft.embedding_dimensions_evidence),
579
+ value_pair(draft.model_revision_evidence),
580
+ value_pair(draft.tokenizer_evidence)
581
+ ]
582
+ end
583
+
584
+ def value_pair(evidence)
585
+ [evidence.status, evidence.value]
586
+ end
587
+ end
588
+
589
+ # ── Settings display health helpers (D14) ────────────────────────────
590
+ module DisplayHealth
591
+ private
592
+
593
+ # Display-only health/capabilities for the status API, written after
594
+ # each registry commit. The key is the CONFIG name
595
+ # (settings[:instances] key), never the derived instance_id.
596
+ # Routing authority stays the in-memory AvailabilityFact; this hash
597
+ # is never read by the router.
598
+ def sync_display_health(state:)
599
+ entry = instance_settings_entry(name: state[:name])
600
+ return unless entry.is_a?(Hash)
601
+
602
+ entry.merge!(display_health_entry(state: state))
603
+ rescue StandardError => e
604
+ handle_exception(e, level: :warn, operation: 'gemini.actor.sync_display_health',
605
+ instance_id: state[:instance_id])
606
+ end
607
+
608
+ def display_health_entry(state:)
609
+ record = publisher.snapshot.instance(instance_key: state[:instance_key])
610
+ status = publisher.snapshot.publication_status(instance_key: state[:instance_key])
611
+ {
612
+ health: display_health(availability: record&.availability, status: status),
613
+ capabilities: instance_capabilities(state[:offerings])
614
+ }
615
+ end
616
+
617
+ def clear_display_health(name:)
618
+ entry = instance_settings_entry(name: name)
619
+ return unless entry.is_a?(Hash)
620
+
621
+ entry.delete(:health)
622
+ entry.delete(:capabilities)
623
+ rescue StandardError => e
624
+ handle_exception(e, level: :warn, operation: 'gemini.actor.clear_display_health',
625
+ instance_name: name.to_s)
626
+ end
627
+
628
+ def instance_settings_entry(name:)
629
+ instances = settings[:instances]
630
+ return nil unless instances.is_a?(Hash)
631
+
632
+ instances[name] || instances[name.to_s]
633
+ end
634
+
635
+ def display_health(availability:, status:)
636
+ available = availability&.state == :available
637
+ {
638
+ circuit_state: available ? :closed : :open,
639
+ denied: false,
640
+ available: available,
641
+ adjustment: available ? 0 : -50,
642
+ reason: health_reason(availability: availability, status: status),
643
+ observed_at: health_observed_at(availability: availability, status: status),
644
+ last_probe_outcome: status.last_probe_outcome,
645
+ source: health_source(availability: availability)
646
+ }
647
+ end
648
+
649
+ def health_reason(availability:, status:)
650
+ availability&.reason || status.last_error
651
+ end
652
+
653
+ def health_observed_at(availability:, status:)
654
+ availability&.observed_at || status.last_probe_completed_at
655
+ end
656
+
657
+ def health_source(availability:)
658
+ availability&.source || :initial_readiness
659
+ end
660
+
661
+ def instance_capabilities(offerings)
662
+ offerings.flat_map do |draft|
663
+ draft.capability_evidence.filter_map do |capability, evidence|
664
+ evidence.supported? ? capability : nil
665
+ end
666
+ end.uniq.sort
667
+ end
668
+ end
669
+
670
+ # ── Instance component helpers ───────────────────────────────────────
671
+ module InstanceComponents
672
+ private
673
+
674
+ def build_instance_key(instance_id:, physical_id: nil)
675
+ Legion::Extensions::Llm::Inventory::Identity::InstanceKey.new(
676
+ provider_family: :gemini, instance_id: instance_id, physical_id: physical_id
677
+ )
678
+ end
679
+
680
+ def build_instance_components(instance_id:, instance_cfg:, instance_key:)
681
+ callable = GeminiCallable.new(instance_cfg: instance_cfg, logger: log)
682
+ probe_coordinator = Legion::Extensions::Llm::Inventory::ProbeCoordinator.new(
683
+ instance_key: instance_key, enqueue: build_probe_enqueue(instance_id: instance_id)
684
+ )
685
+ publisher_token = publisher.claim_instance(
686
+ instance_id: instance_id, callable: callable, probe_request_handle: probe_coordinator,
687
+ physical_id: instance_key.physical_id
688
+ )
689
+ { callable: callable, probe_coordinator: probe_coordinator, publisher_token: publisher_token }
690
+ end
691
+
692
+ def claim_and_activate_instance(name:, instance_cfg:)
693
+ # Identity is the operator's CONFIG NAME (the key the router
694
+ # looks up in instances.<name>); the derived host:port/ak id is
695
+ # the secondary physical id (dedup/diagnostics only).
696
+ instance_id = name.to_s
697
+ physical_id = derive_physical_id(instance_cfg: instance_cfg)
698
+ instance_key = build_instance_key(instance_id: instance_id, physical_id: physical_id)
699
+ components = build_instance_components(instance_id: instance_id, instance_cfg: instance_cfg,
700
+ instance_key: instance_key)
701
+ offerings = discover_offerings_for_instance(instance_cfg: instance_cfg, instance_key: instance_key)
702
+ state = {
703
+ name: name,
704
+ instance_id: instance_id,
705
+ instance_key: instance_key,
706
+ instance_cfg: instance_cfg,
707
+ callable: components[:callable],
708
+ probe_coordinator: components[:probe_coordinator],
709
+ publisher_token: components[:publisher_token],
710
+ sequence: 0,
711
+ offerings: offerings
712
+ }
713
+ settle_initial_readiness(instance_id: instance_id, state: state)
714
+ @instance_states[instance_id] = state
715
+ sync_display_health(state: state)
716
+ end
717
+
718
+ def drop_instance(instance_id:, state:)
719
+ publisher.remove_instance(
720
+ instance_id: instance_id, publisher_token: state[:publisher_token],
721
+ physical_id: state[:instance_key].physical_id
722
+ )
723
+ state[:callable]&.disconnect
724
+ clear_display_health(name: state[:name])
725
+ @instance_states.delete(instance_id)
726
+ rescue StandardError => e
727
+ handle_exception(e, level: :warn, operation: 'gemini.actor.remove_instance',
728
+ instance_id: instance_id)
729
+ end
730
+ end
731
+
732
+ # Per-instance SSOT lifecycle: reconcile configured instances each
733
+ # tick, run the readiness state machine (initial probe, recovery
734
+ # while :initializing, cadence probes, snapshot replacement), and
735
+ # retire instances on shutdown.
736
+ module InstanceLifecycle
737
+ private
738
+
739
+ def initial_discovery
740
+ @instance_states = Concurrent::Map.new
741
+ reconcile_and_refresh
742
+ end
743
+
744
+ def tick_refresh = reconcile_and_refresh
745
+
746
+ # Re-scans configured instances every tick so instances configured
747
+ # after boot appear without a restart and instances removed from
748
+ # settings are retired from the registry. Instances claimed THIS
749
+ # tick are not refreshed again in the same pass — their initial
750
+ # probe just ran; refresh and cadence probes start next tick.
751
+ def reconcile_and_refresh
752
+ configured = configured_instances
753
+ existing = @instance_states.keys
754
+ add_newly_configured_instances(configured: configured)
755
+ remove_unconfigured_instances(configured: configured)
756
+ @instance_states.each do |instance_id, state|
757
+ next unless existing.include?(instance_id)
758
+
759
+ refresh_instance(instance_id: instance_id, state: state)
760
+ rescue StandardError => e
761
+ handle_exception(e, level: :warn, operation: 'gemini.actor.refresh_instance',
762
+ instance_id: instance_id)
763
+ end
764
+ end
765
+
766
+ def add_newly_configured_instances(configured:)
767
+ configured.each do |name, instance_cfg|
768
+ # Dedup on the CONFIG NAME (the identity), not the physical
769
+ # id: two config names pointing at the same endpoint are
770
+ # distinct instances (the physical id never participates in
771
+ # identity).
772
+ next if @instance_states.key?(name.to_s)
773
+
774
+ claim_and_activate_instance(name: name, instance_cfg: instance_cfg)
775
+ rescue StandardError => e
776
+ handle_exception(e, level: :warn, operation: 'gemini.actor.claim_instance', instance_name: name.to_s)
777
+ end
778
+ end
779
+
780
+ def remove_unconfigured_instances(configured:)
781
+ @instance_states.each_value do |state|
782
+ next if configured.key?(state[:name])
783
+
784
+ drop_instance(instance_id: state[:instance_id], state: state)
785
+ end
786
+ end
787
+
788
+ # Starts the readiness probe and settles it: on success activates
789
+ # the current offerings (sequence 0 — valid only while the scope
790
+ # is still :initializing), on failure records it and the instance
791
+ # stays :initializing for the next tick's retry.
792
+ def settle_initial_readiness(instance_id:, state:)
793
+ physical_id = state[:instance_key].physical_id
794
+ probe_token = publisher.readiness_probe_started(
795
+ instance_id: instance_id, publisher_token: state[:publisher_token], physical_id: physical_id
796
+ )
797
+ readiness = check_health(instance_cfg: state[:instance_cfg])
798
+ if readiness.ready?
799
+ publisher.activate_instance_snapshot(
800
+ instance_id: instance_id, publisher_token: state[:publisher_token],
801
+ offerings: state[:offerings], sequence: 0, probe_token: probe_token, physical_id: physical_id
802
+ )
803
+ else
804
+ publisher.readiness_failed(
805
+ instance_id: instance_id, probe_token: probe_token, reason: readiness.reason,
806
+ physical_id: physical_id
807
+ )
808
+ end
809
+ end
810
+
811
+ def refresh_instance(instance_id:, state:)
812
+ if publication_state(instance_key: state[:instance_key]) == :initializing
813
+ retry_initial_activation(instance_id: instance_id, state: state)
814
+ else
815
+ replace_offerings_if_changed(instance_id: instance_id, state: state)
816
+ run_cadence_probe(instance_id: instance_id, state: state)
817
+ end
818
+ end
819
+
820
+ def publication_state(instance_key:)
821
+ publisher.snapshot.publication_status(instance_key: instance_key).state
822
+ end
823
+
824
+ # An instance that failed initial readiness stays :initializing —
825
+ # readiness_succeeded and replace_instance_snapshot both refuse to
826
+ # operate on an :initializing scope, so without this re-activation
827
+ # path a transient outage at boot pins the instance for the process
828
+ # lifetime. Re-probe each tick and activate once readiness passes.
829
+ def retry_initial_activation(instance_id:, state:)
830
+ state[:offerings] = discover_offerings_for_instance(
831
+ instance_cfg: state[:instance_cfg], instance_key: state[:instance_key]
832
+ )
833
+ settle_initial_readiness(instance_id: instance_id, state: state)
834
+ sync_display_health(state: state)
835
+ end
836
+
837
+ def replace_offerings_if_changed(instance_id:, state:)
838
+ new_offerings = discover_offerings_for_instance(
839
+ instance_cfg: state[:instance_cfg], instance_key: state[:instance_key]
840
+ )
841
+ return unless offerings_changed?(previous: state[:offerings], current: new_offerings)
842
+
843
+ state[:sequence] += 1
844
+ publisher.replace_instance_snapshot(
845
+ instance_id: instance_id, publisher_token: state[:publisher_token],
846
+ offerings: new_offerings, sequence: state[:sequence],
847
+ physical_id: state[:instance_key].physical_id
848
+ )
849
+ state[:offerings] = new_offerings
850
+ sync_display_health(state: state)
851
+ end
852
+
853
+ def remove_all_instances
854
+ return unless @instance_states
855
+
856
+ @instance_states.each_value do |state|
857
+ drop_instance(instance_id: state[:instance_id], state: state)
858
+ end
859
+ @instance_states.clear
860
+ end
861
+ end
862
+
863
+ # SSOT v3 periodic discovery actor for Gemini provider instances.
864
+ # Claims configured instances, discovers models via the Gemini models
865
+ # API, probes health via model listing, and publishes complete
866
+ # OfferingDraft snapshots through the Inventory::Publisher. Recovers
867
+ # instances that fail initial readiness and supports coalesced
868
+ # reactive probes after dispatch-triggered instance_unavailable
869
+ # transitions.
870
+ class DiscoveryRefresh < Legion::Extensions::Actors::Every
871
+ include Legion::Extensions::Helpers::Lex
872
+ include Legion::Logging::Helper
873
+ include EvidenceBuilder
874
+ include ValueEvidenceBuilder
875
+ include ModelDiscovery
876
+ include ConfigResolver
877
+ include HttpClient
878
+ include ProbeRunner
879
+ include HealthChecker
880
+ include OfferingComparison
881
+ include DisplayHealth
882
+ include InstanceComponents
883
+ include InstanceLifecycle
20
884
 
21
885
  def runner_class = self.class
22
886
  def runner_function = 'manual'
@@ -25,26 +889,192 @@ module Legion
25
889
  def check_subtask? = false
26
890
  def generate_task? = false
27
891
 
892
+ # The registered discovery interval lives under instances.default
893
+ # (provider_settings nests it there). Never return nil — a nil
894
+ # execution_interval makes the TimerTask fire exactly once and then
895
+ # stop, killing all refresh, probes, and recovery.
28
896
  def time
29
- return REFRESH_INTERVAL unless defined?(Legion::Settings)
897
+ interval = settings.dig(:instances, :default, :discovery_interval)
898
+ return interval if interval.is_a?(::Integer) && interval.positive?
30
899
 
31
- Legion::Settings.dig(:extensions, :llm, :gemini, :discovery_interval) || REFRESH_INTERVAL
900
+ Legion::Extensions::Llm::Gemini.default_settings.dig(:instances, :default, :discovery_interval)
32
901
  end
33
902
 
34
903
  def manual
35
- log.debug('[gemini][discovery_refresh] refreshing model list')
36
- return unless defined?(Legion::LLM::Discovery)
904
+ if @initialized
905
+ tick_refresh
906
+ else
907
+ initial_discovery
908
+ @initialized = true
909
+ end
910
+ rescue StandardError => e
911
+ handle_exception(e, level: :warn, operation: 'gemini.actor.discovery_refresh')
912
+ end
37
913
 
38
- Legion::LLM::Discovery.refresh_discovered_models!(provider: :gemini)
914
+ def shutdown
915
+ remove_all_instances
916
+ rescue StandardError => e
917
+ handle_exception(e, level: :warn, operation: 'gemini.actor.discovery_refresh.shutdown')
918
+ end
39
919
 
40
- if defined?(Legion::LLM::Router) && Legion::LLM::Router.respond_to?(:populate_auto_rules)
41
- Legion::LLM::Router.populate_auto_rules(Legion::LLM::Discovery.discovered_instances)
42
- end
43
- if defined?(Legion::LLM::Inventory) && Legion::LLM::Inventory.respond_to?(:invalidate_offerings_cache!)
44
- Legion::LLM::Inventory.invalidate_offerings_cache!
920
+ private
921
+
922
+ def publisher
923
+ @publisher ||= Legion::Extensions::Llm::Inventory::Publisher.new(
924
+ provider_family: :gemini,
925
+ compatibility_adapter: Legion::Extensions::Llm::Inventory::ScopedRefresher::LegacyCoordinatorAdapter.new(
926
+ provider_family: :gemini
927
+ )
928
+ )
929
+ end
930
+ end
931
+
932
+ # Callable wrapper for a Gemini provider instance. Implements the
933
+ # fleet dispatch ops (chat/stream_chat/embed/count_tokens) by
934
+ # delegating to a per-instance Gemini::Provider, plus the
935
+ # disconnect and normalize_dispatch_error contracts required by
936
+ # Inventory::CallableHandle and Routing::ProviderOutcome. Dispatch
937
+ # errors propagate untouched so normalize_dispatch_error can
938
+ # classify them.
939
+ class GeminiCallable
940
+ def initialize(instance_cfg:, logger:, provider: nil)
941
+ @instance_cfg = instance_cfg
942
+ @logger = logger
943
+ @provider = provider
944
+ @disconnected = false
945
+ end
946
+
947
+ def disconnected? = @disconnected
948
+
949
+ def disconnect
950
+ @disconnected = true
951
+ @provider&.disconnect
952
+ @logger.debug { '[gemini][callable] disconnected' }
953
+ end
954
+
955
+ # Fleet and SelectionDispatch pass model as a RAW STRING (the
956
+ # offering's model id). Gemini's render path calls model.id
957
+ # (MessageFormatter#render_payload), so a raw string must be
958
+ # wrapped before delegation; Model::Info instances pass through.
959
+ def chat(messages:, model:, **rest)
960
+ provider.chat(messages: messages, model: llm_model(model), **rest)
961
+ end
962
+
963
+ def stream_chat(messages:, model:, **rest, &)
964
+ provider.stream_chat(messages: messages, model: llm_model(model), **rest, &)
965
+ end
966
+
967
+ def embed(text:, model:, **rest)
968
+ provider.embed(text: text, model: llm_model(model), **rest)
969
+ end
970
+
971
+ def count_tokens(messages:, model:, **rest)
972
+ provider.count_tokens(messages: messages, model: llm_model(model), **rest)
973
+ end
974
+
975
+ def normalize_dispatch_error(error:)
976
+ reason = error.message.to_s[0, 512]
977
+ Legion::Extensions::Llm::Routing::ProviderOutcome.new(
978
+ kind: classify_dispatch_error(error: error), reason: reason.empty? ? 'unknown dispatch error' : reason
979
+ )
980
+ end
981
+
982
+ private
983
+
984
+ def llm_model(model)
985
+ return model if model.respond_to?(:id)
986
+
987
+ Legion::Extensions::Llm::Model::Info.new(id: model.to_s, provider: :gemini)
988
+ end
989
+
990
+ def provider = @provider ||= build_provider
991
+
992
+ def build_provider
993
+ Legion::Extensions::Llm::Gemini::Provider.new(
994
+ {
995
+ gemini_api_key: @instance_cfg[:gemini_api_key] || @instance_cfg[:api_key] ||
996
+ @instance_cfg.dig(:credentials, :api_key),
997
+ gemini_api_base: @instance_cfg[:gemini_api_base] || @instance_cfg[:endpoint]
998
+ }.compact
999
+ )
1000
+ end
1001
+
1002
+ def classify_dispatch_error(error:)
1003
+ return :connection_failure if error.is_a?(Faraday::ConnectionFailed)
1004
+ return :timeout if error.is_a?(Faraday::TimeoutError)
1005
+ return :overloaded if error.is_a?(Legion::Extensions::Llm::OverloadedError)
1006
+ return classify_by_status(error: error) if http_status_error?(error)
1007
+
1008
+ :provider_error
1009
+ end
1010
+
1011
+ def http_status_error?(error)
1012
+ error.is_a?(Faraday::ClientError) || error.is_a?(Faraday::ServerError) ||
1013
+ error.is_a?(Legion::Extensions::Llm::Error)
1014
+ end
1015
+
1016
+ # §8 health firewall: only the explicit Gemini UNAVAILABLE body
1017
+ # signal maps to :instance_unavailable. Status code alone (503/529)
1018
+ # never does — those are request-local overload conditions.
1019
+ def classify_by_status(error:)
1020
+ return :instance_unavailable if explicit_service_unavailable?(error: error)
1021
+
1022
+ status = dispatch_status(error)
1023
+ return :model_not_ready if status.is_a?(::Integer) && status >= 500 &&
1024
+ model_not_ready_signal?(error: error)
1025
+
1026
+ status_kind(status)
1027
+ end
1028
+
1029
+ def status_kind(status)
1030
+ case status
1031
+ when 401 then :authentication
1032
+ when 403 then :authorization
1033
+ when 404 then :model_missing
1034
+ when 429 then :rate_limited
1035
+ when 503, 529 then :overloaded
1036
+ when 400...500 then :invalid_request
1037
+ else :provider_error
45
1038
  end
46
- rescue StandardError => e
47
- handle_exception(e, level: :warn, handled: true, operation: 'gemini.actor.discovery_refresh')
1039
+ end
1040
+
1041
+ # Returns true only when the Gemini API response body explicitly
1042
+ # carries "status":"UNAVAILABLE" — the flat service-level
1043
+ # unavailability signal distinct from throttling
1044
+ # (RESOURCE_EXHAUSTED) or model loading.
1045
+ def explicit_service_unavailable?(error:)
1046
+ body = response_body_string(error)
1047
+ return false if body.nil?
1048
+
1049
+ (body.include?('"status":"UNAVAILABLE"') || body.include?('"status": "UNAVAILABLE"')) &&
1050
+ !body.include?('RESOURCE_EXHAUSTED')
1051
+ end
1052
+
1053
+ def model_not_ready_signal?(error:)
1054
+ body = response_body_string(error)&.downcase
1055
+ body.to_s.include?('model not ready') || body.to_s.include?('model is still loading')
1056
+ end
1057
+
1058
+ # Reads the body from every real Faraday error shape: Faraday::Env
1059
+ # (Faraday 2.x — a Struct, NOT a Hash, which is why an
1060
+ # is_a?(Hash) gate here is dead in production), Faraday::Response
1061
+ # (lex-llm ErrorMiddleware), or the plain response Hash (Faraday
1062
+ # RaiseError middleware / Faraday 1.x).
1063
+ def response_body_string(error)
1064
+ response = error.respond_to?(:response) ? error.response : nil
1065
+ return nil unless response
1066
+
1067
+ body = response.respond_to?(:body) ? response.body : (response[:body] if response.respond_to?(:[]))
1068
+ return body if body.is_a?(String)
1069
+
1070
+ body && ::JSON.generate(body)
1071
+ end
1072
+
1073
+ def dispatch_status(error)
1074
+ return error.response_status if error.respond_to?(:response_status) && error.response_status
1075
+
1076
+ response = error.respond_to?(:response) ? error.response : nil
1077
+ response.respond_to?(:status) ? response.status : nil
48
1078
  end
49
1079
  end
50
1080
  end