lex-llm-gemini 0.3.10 → 0.3.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 101069a5f61b6c4a90fe236e40c54ef3af55bdd00d7b6816495eb255aa61cad4
4
- data.tar.gz: adf84ed406fb7419fab87b7a0fc42b123abda739b20889dec8aead89c3adffef
3
+ metadata.gz: 564af323ba9c521e6404595eb47f3c1470da8459aec2790c42f51d004e3c2394
4
+ data.tar.gz: 4a7bce02b3c4399425ed0749ca5fb580e67d445af04c85480c7b1c0d33a01782
5
5
  SHA512:
6
- metadata.gz: 4b9d7c32d2953485fcd5000b7139012eb1d69470936d8e7a0eadf56b030bbf1b412754f392248c58ff028d17d2e037464384442674a7d5e46699b8844c25202a
7
- data.tar.gz: 941d8fa9761befe73c8324db0452807f073c644f958a0d174dd68fd27be9448172653110dc6cb4e6cd739ea88419c1e0a4f8c5ce614939590171fb36db8f6233
6
+ metadata.gz: 62157a365a6a265791ae7ec076332976241b3137076e5b8fa5f389625b035679ac38afa9ff16324b578f1ab2d9cd35e33b30564eb37a7870eda05dee66ae40cf
7
+ data.tar.gz: 98c631924f43613cf28f034a972ef8b2326d88286364768094640080a68e5daf73bdd945ce60867cce6f58f75d6640fc65c0235738c3e7a3d7dd374602f0069f
data/CHANGELOG.md CHANGED
@@ -1,5 +1,51 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.3.18] - 2026-08-04
4
+
5
+ ### Changed
6
+ - Prepare the Gemini provider standardization baseline for a patch release.
7
+
8
+ ## [0.3.17] - 2026-07-02
9
+
10
+ ### Fixed
11
+ - Emit canonical capability vocabulary (`:embedding`, `:tools`) from `Capabilities.critical_capabilities_for`. `lex-llm` 0.6.1+ retains both the raw and canonical forms on `Model::Info`, so the pre-canonical aliases (`embeddings`, `function_calling`) leaked duplicate tokens (e.g. `[:embeddings, :embedding]`) into discovered model capabilities.
12
+
13
+ ## [0.3.16] - 2026-06-20
14
+
15
+ ### Fixed
16
+ - Stub shared registry publishing through `RegistryPublisher#schedule` in specs so async availability-event coverage stays stable after the shared publisher moved off raw `Thread.new`.
17
+
18
+ ## [0.3.15] - 2026-06-20
19
+
20
+ ### Changed
21
+ - Align Gemini instance discovery with the shared `lex-llm` contract by preserving explicit tier overrides while defaulting unconfigured instances to `:cloud`.
22
+ - Expand instance-level capability override passthrough to the shared `enable_*` vocabulary, including `structured_output`.
23
+ - Normalize Gemini contract coverage around canonical `:tools` and `:embedding` capabilities.
24
+
25
+ ## [0.3.14] - 2026-06-19
26
+
27
+ ### Changed
28
+ - Adopt `Legion::Extensions::Llm::Inventory::ScopedRefresher` mixin (lex-llm 0.6.0). Discovery
29
+ refresh actors now write directly to the live `Inventory` catalog via `Inventory.write_lane`.
30
+ - Pin `lex-llm >= 0.6.0` and `legion-llm >= 0.14.0` in gemspec.
31
+ - Standard `weight: 100` default added to provider instance settings schema.
32
+
33
+ ## 0.3.13 - 2026-06-16
34
+
35
+ - Dependency updates, code quality improvements.
36
+
37
+ ## 0.3.12 - 2026-06-15
38
+
39
+ - **CapabilityPolicy integration** — `supportedGenerationMethods` mapped to `:model_metadata`. Settings overrides at provider/instance/model level supported.
40
+
41
+ ## 0.3.11 - 2026-06-13
42
+
43
+ - **Gemfile cleanup** — Remove local path overrides; dependencies resolve from gemspec via rubygems.
44
+ - **Dependency bump** — Require `lex-llm >= 0.5.0` for canonical types support.
45
+ - **Canonical tool support** — Use `ToolSchema.extract` and add `:tools` capability.
46
+ - **Bug fix** — Handle Array tool_calls in `tool_call_parts`.
47
+ - 22 examples, 0 failures; 13 files, 0 rubocop offenses.
48
+
3
49
  ## 0.3.10 - 2026-06-02
4
50
 
5
51
  - Add per-provider scoped discovery refresh actor
data/Gemfile CHANGED
@@ -2,13 +2,6 @@
2
2
 
3
3
  source 'https://rubygems.org'
4
4
 
5
- group :test do
6
- llm_base_path = ENV.fetch('LEX_LLM_PATH', File.expand_path('../lex-llm', __dir__))
7
- transport_path = ENV.fetch('LEGION_TRANSPORT_PATH', File.expand_path('../../legion-transport', __dir__))
8
- gem 'legion-transport', path: transport_path if File.directory?(transport_path)
9
- gem 'lex-llm', path: llm_base_path if File.directory?(llm_base_path)
10
- end
11
-
12
5
  gemspec
13
6
 
14
7
  group :development do
@@ -27,5 +27,5 @@ Gem::Specification.new do |spec|
27
27
  spec.add_dependency 'legion-logging', '>= 1.3.2'
28
28
  spec.add_dependency 'legion-settings', '>= 1.3.14'
29
29
  spec.add_dependency 'legion-transport', '>= 1.4.14'
30
- spec.add_dependency 'lex-llm', '>= 0.4.3'
30
+ spec.add_dependency 'lex-llm', '>= 0.6.0'
31
31
  end
@@ -1,11 +1,19 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'digest'
4
+
3
5
  begin
4
6
  require 'legion/extensions/actors/every'
5
7
  rescue LoadError => e
6
8
  warn(e.message) if $VERBOSE
7
9
  end
8
10
 
11
+ begin
12
+ require 'legion/extensions/llm/inventory/scoped_refresher'
13
+ rescue LoadError => e
14
+ warn(e.message) if $VERBOSE
15
+ end
16
+
9
17
  return unless defined?(Legion::Extensions::Actors::Every)
10
18
 
11
19
  module Legion
@@ -13,10 +21,14 @@ module Legion
13
21
  module Llm
14
22
  module Gemini
15
23
  module Actor
16
- class DiscoveryRefresh < Legion::Extensions::Actors::Every # rubocop:disable Style/Documentation
24
+ class DiscoveryRefresh < Legion::Extensions::Actors::Every # rubocop:disable Style/Documentation,Metrics/ClassLength
17
25
  include Legion::Logging::Helper
18
26
 
19
- REFRESH_INTERVAL = 1800
27
+ if defined?(Legion::Extensions::Llm::Inventory::ScopedRefresher)
28
+ include Legion::Extensions::Llm::Inventory::ScopedRefresher
29
+ end
30
+
31
+ def self.every_seconds = 3600
20
32
 
21
33
  def runner_class = self.class
22
34
  def runner_function = 'manual'
@@ -26,19 +38,137 @@ module Legion
26
38
  def generate_task? = false
27
39
 
28
40
  def time
29
- return REFRESH_INTERVAL unless defined?(Legion::Settings)
41
+ return self.class.every_seconds unless defined?(Legion::Settings)
30
42
 
31
- Legion::Settings.dig(:extensions, :llm, :gemini, :discovery_interval) || REFRESH_INTERVAL
43
+ Legion::Settings.dig(:extensions, :llm, :gemini, :discovery_interval) || self.class.every_seconds
32
44
  end
33
45
 
34
- def manual
35
- log.debug('[gemini][discovery_refresh] refreshing model list')
36
- return unless defined?(Legion::LLM::Discovery)
46
+ def scope_key(**)
47
+ { provider: :gemini }
48
+ end
37
49
 
38
- Legion::LLM::Discovery.refresh_discovered_models!(provider: :gemini)
50
+ def compute_lanes_for_scope(**)
51
+ return [] unless defined?(Legion::LLM::Call::Registry)
52
+
53
+ lanes = []
54
+ gemini_instances.each do |instance|
55
+ collect_lanes_for_instance(instance, lanes)
56
+ rescue StandardError => e
57
+ handle_exception(e, level: :warn, handled: true,
58
+ operation: 'gemini.discovery_refresh.compute_lanes',
59
+ instance: instance[:id])
60
+ end
61
+ lanes
62
+ rescue StandardError => e
63
+ handle_exception(e, level: :warn, handled: true,
64
+ operation: 'gemini.discovery_refresh.compute_lanes_for_scope')
65
+ []
66
+ end
67
+
68
+ def credential_hash(**)
69
+ settings = gemini_settings
70
+ Digest::SHA256.hexdigest(settings[:api_key].to_s + settings[:instances].to_s)[0, 16]
71
+ rescue StandardError => e
72
+ handle_exception(e, level: :warn, handled: true, operation: 'gemini.discovery_refresh.credential_hash')
73
+ 'unknown'
74
+ end
75
+
76
+ def manual(**)
77
+ tick if defined?(Legion::Extensions::Llm::Inventory::ScopedRefresher) &&
78
+ respond_to?(:tick, true)
39
79
  rescue StandardError => e
40
80
  handle_exception(e, level: :warn, handled: true, operation: 'gemini.actor.discovery_refresh')
41
81
  end
82
+
83
+ private
84
+
85
+ def gemini_instances
86
+ Legion::LLM::Call::Registry.all_instances.select do |e|
87
+ (e[:provider] || '').to_sym == :gemini
88
+ end
89
+ end
90
+
91
+ def collect_lanes_for_instance(instance, lanes)
92
+ adapter = instance[:adapter]
93
+ return unless adapter.respond_to?(:discover_offerings)
94
+
95
+ Array(adapter.discover_offerings(live: false)).each do |raw_offering|
96
+ offering = offering_to_hash(raw_offering)
97
+ next unless offering
98
+
99
+ model = offering[:model] || offering['model']
100
+ next unless model
101
+
102
+ lane = build_lane(offering, instance)
103
+ lanes << lane
104
+ lanes << fleet_lane(lane, instance) if emit_fleet_lane?(lane)
105
+ end
106
+ end
107
+
108
+ def offering_to_hash(offering)
109
+ return nil if offering.nil?
110
+ return offering if offering.is_a?(Hash)
111
+
112
+ hash = offering.to_h
113
+ hash[:type] ||= hash[:usage_type]
114
+ hash[:enabled] = offering.respond_to?(:enabled?) ? offering.enabled? : true
115
+ hash
116
+ end
117
+
118
+ def build_lane(offering, instance) # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
119
+ instance_id = instance[:instance] || instance[:instance_id] || instance[:id]
120
+ raw_tier = offering[:tier] || :frontier
121
+ offer_type = offering[:type]
122
+ type = %i[embed embedding].include?(offer_type) ? :embedding : :inference
123
+ capabilities = normalize_capabilities(offering[:capabilities] || [])
124
+ model = offering[:model] || offering['model']
125
+
126
+ lane_id = Legion::Extensions::Llm::Inventory::ScopedRefresher.compose_id(
127
+ tier: raw_tier, provider_family: :gemini,
128
+ instance_id: instance_id, type: type, model: model
129
+ )
130
+
131
+ {
132
+ id: lane_id,
133
+ tier: raw_tier,
134
+ provider_family: :gemini,
135
+ instance_id: instance_id,
136
+ model: model,
137
+ canonical_model_alias: offering[:canonical_model_alias] || offering[:name],
138
+ type: type,
139
+ capabilities: capabilities,
140
+ limits: offering[:limits] || {},
141
+ enabled: offering.fetch(:enabled, true),
142
+ cost: offering[:cost] || {}
143
+ }
144
+ end
145
+
146
+ def emit_fleet_lane?(lane)
147
+ return false unless lane[:type] == :inference
148
+
149
+ gemini_settings&.dig(:fleet, :dispatch, :enabled)
150
+ end
151
+
152
+ def fleet_lane(lane, instance)
153
+ fleet_id = Legion::Extensions::Llm::Inventory::ScopedRefresher.compose_id(
154
+ tier: :fleet, provider_family: :gemini,
155
+ instance_id: instance[:instance] || instance[:instance_id] || instance[:id],
156
+ type: lane[:type], model: lane[:model]
157
+ )
158
+ lane.merge(id: fleet_id, tier: :fleet)
159
+ end
160
+
161
+ def normalize_capabilities(caps)
162
+ return [] unless defined?(Legion::Extensions::Llm::Inventory::Capabilities)
163
+
164
+ Legion::Extensions::Llm::Inventory::Capabilities.normalize(caps)
165
+ end
166
+
167
+ def gemini_settings
168
+ Legion::Settings.dig(:extensions, :llm, :gemini)
169
+ rescue StandardError
170
+ {}
171
+ end
42
172
  end
43
173
  end
44
174
  end
@@ -33,11 +33,14 @@ module Legion
33
33
  def vision?(model) = chat?(model) && model_id(model).match?(/gemini|flash|pro/)
34
34
  def functions?(model) = chat?(model)
35
35
 
36
+ # Emit canonical capability vocabulary (see Legion::Extensions::Llm::Capabilities::CANONICAL).
37
+ # Model::Info retains both raw and canonical forms, so aliases like :embeddings/:function_calling
38
+ # would otherwise leak duplicate tokens into the capability list.
36
39
  def critical_capabilities_for(model)
37
40
  [
38
41
  ('streaming' if streaming?(model)),
39
- ('embeddings' if embeddings?(model)),
40
- ('function_calling' if functions?(model)),
42
+ ('embedding' if embeddings?(model)),
43
+ ('tools' if functions?(model)),
41
44
  ('vision' if vision?(model))
42
45
  ].compact
43
46
  end
@@ -187,7 +190,9 @@ module Legion
187
190
  end
188
191
 
189
192
  def tool_call_parts(message)
190
- message.tool_calls.values.map do |tool_call|
193
+ # Array is canonical (name-keyed hashes dropped parallel same-name calls)
194
+ calls = message.tool_calls.is_a?(Hash) ? message.tool_calls.values : Array(message.tool_calls)
195
+ calls.map do |tool_call|
191
196
  { functionCall: { name: tool_call.name, args: tool_call.arguments } }
192
197
  end
193
198
  end
@@ -204,9 +209,12 @@ module Legion
204
209
  def format_tools(tools)
205
210
  [{
206
211
  functionDeclarations: tools.values.map do |tool|
207
- declaration = { name: tool.name, description: tool.description }
208
- declaration[:parameters] = tool.params_schema if tool.params_schema
209
- declaration
212
+ schema = Legion::Extensions::Llm::Canonical::ToolSchema.extract(tool)
213
+ {
214
+ name: Legion::Extensions::Llm::Canonical::ToolSchema.tool_name(tool),
215
+ description: Legion::Extensions::Llm::Canonical::ToolSchema.tool_description(tool),
216
+ parameters: schema
217
+ }
210
218
  end
211
219
  }]
212
220
  end
@@ -307,6 +315,85 @@ module Legion
307
315
  end
308
316
  end
309
317
 
318
+ # -- policy resolution requires many sources
319
+ def offering_from_model(model, health: {})
320
+ policy = Legion::Extensions::Llm::CapabilityPolicy.resolve(
321
+ real: real_capabilities_from(model),
322
+ provider_catalog: {},
323
+ probe: {},
324
+ provider_envelope: {},
325
+ provider_config: provider_capability_config,
326
+ instance_config: instance_capability_config,
327
+ model_config: model_capability_config(model.id)
328
+ )
329
+
330
+ build_model_offering(model, policy, health)
331
+ end
332
+
333
+ def build_model_offering(model, policy, health)
334
+ Routing::ModelOffering.new(
335
+ provider_family: slug.to_sym,
336
+ provider_instance: model.instance || provider_instance_id,
337
+ transport: offering_transport,
338
+ tier: offering_tier,
339
+ model: model.id,
340
+ canonical_model_alias: model.name,
341
+ model_family: model.family,
342
+ usage_type: offering_usage_type(model),
343
+ capabilities: policy[:capabilities],
344
+ capability_sources: policy[:sources],
345
+ limits: offering_limits(model),
346
+ health:,
347
+ metadata: offering_metadata(model)
348
+ )
349
+ end
350
+
351
+ def real_capabilities_from(model)
352
+ meta = model.respond_to?(:metadata) ? (model.metadata || {}) : {}
353
+ methods = Array(meta[:supported_generation_methods] || meta['supported_generation_methods'])
354
+ {
355
+ streaming: methods.include?('streamGenerateContent'),
356
+ embedding: methods.include?('embedContent'),
357
+ vision: Capabilities.vision?(meta.merge('name' => "models/#{model.id}"))
358
+ }.compact
359
+ end
360
+
361
+ def provider_capability_config
362
+ return {} unless defined?(Legion::Extensions::Llm::CredentialSources)
363
+
364
+ conf = Legion::Extensions::Llm::CredentialSources.setting(:extensions, :llm, :gemini)
365
+ conf.is_a?(Hash) ? conf.to_h.except(:instances, 'instances') : {}
366
+ rescue StandardError => e
367
+ handle_exception(e, level: :warn, handled: true, operation: 'gemini.provider_capability_config')
368
+ {}
369
+ end
370
+
371
+ def instance_capability_config
372
+ return {} unless config.respond_to?(:to_h)
373
+
374
+ config.to_h.slice(*Legion::Extensions::Llm::Provider::CAPABILITY_CONFIG_KEYS)
375
+ end
376
+
377
+ def model_capability_config(model_id)
378
+ models_conf = resolve_models_config
379
+ return {} unless models_conf.respond_to?(:to_h)
380
+
381
+ hash = models_conf.to_h
382
+ hash[model_id.to_s] || hash[model_id.to_sym] || {}
383
+ rescue StandardError => e
384
+ handle_exception(e, level: :warn, handled: true, operation: 'gemini.model_capability_config')
385
+ {}
386
+ end
387
+
388
+ def resolve_models_config
389
+ return config.models if config.respond_to?(:models)
390
+ return config[:models] if config.respond_to?(:[])
391
+
392
+ nil
393
+ rescue StandardError
394
+ nil
395
+ end
396
+
310
397
  def modalities_for(methods)
311
398
  return [%w[text], %w[embeddings]] if methods.include?('embedContent')
312
399
 
@@ -4,7 +4,7 @@ module Legion
4
4
  module Extensions
5
5
  module Llm
6
6
  module Gemini
7
- VERSION = '0.3.10'
7
+ VERSION = '0.3.18'
8
8
  end
9
9
  end
10
10
  end
@@ -3,6 +3,7 @@
3
3
  require 'legion/extensions/llm'
4
4
  require 'legion/extensions/llm/gemini/provider'
5
5
  require 'legion/extensions/llm/gemini/version'
6
+ require_relative 'gemini/actors/discovery_refresh'
6
7
 
7
8
  module Legion
8
9
  module Extensions
@@ -30,10 +31,7 @@ module Legion
30
31
  fleet: {
31
32
  enabled: false,
32
33
  respond_to_requests: false,
33
- capabilities: %i[chat stream_chat embed],
34
- lanes: [],
35
- concurrency: 4,
36
- queue_suffix: nil
34
+ capabilities: %i[chat stream_chat embed tools]
37
35
  }
38
36
  }
39
37
  )
@@ -71,7 +69,7 @@ module Legion
71
69
 
72
70
  candidates[:settings] = normalize_instance_config(cfg).merge(api_key: api_key,
73
71
  gemini_api_key: api_key,
74
- tier: :cloud)
72
+ tier: cfg[:tier] || cfg['tier'] || :cloud)
75
73
  end
76
74
 
77
75
  def self.add_settings_instances(candidates, cfg)
@@ -85,7 +83,8 @@ module Legion
85
83
  next unless normalized[:gemini_api_key]
86
84
 
87
85
  normalized[:api_key] = normalized[:gemini_api_key]
88
- candidates[name.to_sym] = normalized.merge(tier: :cloud)
86
+ normalized[:tier] ||= :cloud
87
+ candidates[name.to_sym] = normalized
89
88
  end
90
89
  end
91
90
 
@@ -106,8 +105,7 @@ module Legion
106
105
  :add_settings_api_key, :add_settings_instances, :normalize_instance_config,
107
106
  :sanitize_instance_config
108
107
 
109
- Legion::Extensions::Llm::Configuration.register_provider_options(Provider.configuration_options) if
110
- Legion::Extensions::Llm::Configuration.respond_to?(:register_provider_options)
108
+ Legion::Extensions::Llm::Configuration.register_provider_options(Provider.configuration_options)
111
109
  end
112
110
  end
113
111
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lex-llm-gemini
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.10
4
+ version: 0.3.18
5
5
  platform: ruby
6
6
  authors:
7
7
  - LegionIO
@@ -71,14 +71,14 @@ dependencies:
71
71
  requirements:
72
72
  - - ">="
73
73
  - !ruby/object:Gem::Version
74
- version: 0.4.3
74
+ version: 0.6.0
75
75
  type: :runtime
76
76
  prerelease: false
77
77
  version_requirements: !ruby/object:Gem::Requirement
78
78
  requirements:
79
79
  - - ">="
80
80
  - !ruby/object:Gem::Version
81
- version: 0.4.3
81
+ version: 0.6.0
82
82
  description: Gemini provider integration for the LegionIO LLM routing framework.
83
83
  email:
84
84
  - matthewdiverson@gmail.com