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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +137 -0
- data/Gemfile +2 -0
- data/lex-llm-gemini.gemspec +2 -2
- data/lib/legion/extensions/llm/gemini/actors/discovery_refresh.rb +1048 -18
- data/lib/legion/extensions/llm/gemini/actors/fleet_worker.rb +7 -3
- data/lib/legion/extensions/llm/gemini/provider.rb +149 -149
- data/lib/legion/extensions/llm/gemini/runners/fleet_worker.rb +10 -4
- data/lib/legion/extensions/llm/gemini/version.rb +1 -1
- data/lib/legion/extensions/llm/gemini.rb +21 -8
- metadata +5 -5
|
@@ -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
|
|
6
|
-
|
|
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
|
-
|
|
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
|
-
|
|
17
|
-
|
|
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
|
-
|
|
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
|
-
|
|
897
|
+
interval = settings.dig(:instances, :default, :discovery_interval)
|
|
898
|
+
return interval if interval.is_a?(::Integer) && interval.positive?
|
|
30
899
|
|
|
31
|
-
Legion::
|
|
900
|
+
Legion::Extensions::Llm::Gemini.default_settings.dig(:instances, :default, :discovery_interval)
|
|
32
901
|
end
|
|
33
902
|
|
|
34
903
|
def manual
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
47
|
-
|
|
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
|