lex-llm-vertex 0.2.10 → 0.2.15

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: 25083d8e7cc928d57127feab13406395a6600421913559042fecfe7e2a376267
4
- data.tar.gz: '028cb717f3299a2683bdd0ed10cc59f91a75f3de5f773d730ec75e2374762170'
3
+ metadata.gz: b1b2e665277fea3299658b992eac2b441a363e3c81dbf87d69e86c7328c6c99c
4
+ data.tar.gz: d81b38ce8e5e797a074d30a670a5e6fa9bcb92762c5dcd066ed86ad8196d5340
5
5
  SHA512:
6
- metadata.gz: 69f44a1e8ac1d5d0da6b9e4ca5b5d5f7f0fba37b49021e5a512db86884958a89abd20323c336b9292c1973fb47e303d37b715322aaf37da75ab1f3d5da33a48f
7
- data.tar.gz: 53cb2cfeb3039b78ca84722943e220178b6a7c1a060972ca329aacd4acb742bac4e4a581d9df819fa1902f315ca73858d9401d29ec08318c7ce0290899b11fc7
6
+ metadata.gz: 97009f1dd75f9f053ec06dfba726627f7c6202f92bb56a0185e9250d7579ff73de1c7c410b3ae6a82b4ad308032384362a0dd5c12ed2eeddc30e3c690d381753
7
+ data.tar.gz: 2f36d16c24610e1355ee50cdeb26e99818e8a1b7cc6441b9ef88f7e0d17130bd8691bba88e8db3c71bc3becd33a1abe5962ee65802a957abdcc6dec7bc4f839d
data/CHANGELOG.md CHANGED
@@ -1,5 +1,37 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.2.15] - 2026-06-20
4
+
5
+ ### Changed
6
+ - Align Vertex offerings to the current `lex-llm` contract: shared `discover_offerings` now rebuilds
7
+ resource-name offerings from discovered models, preserves provider health on offerings, and keeps the
8
+ shared capability-override path intact.
9
+ - Fix the provider tail introduced during the contract refactor so the provider file closes cleanly again.
10
+
11
+ ## [0.2.14] - 2026-06-19
12
+
13
+ ### Changed
14
+ - Adopt `Legion::Extensions::Llm::Inventory::ScopedRefresher` mixin (lex-llm 0.6.0). Discovery
15
+ refresh actors now write directly to the live `Inventory` catalog via `Inventory.write_lane`.
16
+ - Pin `lex-llm >= 0.6.0` and `legion-llm >= 0.14.0` in gemspec.
17
+ - Standard `weight: 100` default added to provider instance settings schema.
18
+
19
+ ## 0.2.13 - 2026-06-16
20
+
21
+ - Dependency updates and code quality improvements.
22
+
23
+ ## 0.2.12 - 2026-06-15
24
+
25
+ - **CapabilityPolicy integration** — Model-family heuristics tagged as `:provider_catalog`; Vertex features as `:model_metadata`. Settings overrides at provider/instance/model level supported.
26
+
27
+ ## 0.2.11 - 2026-06-13
28
+
29
+ - **Gemfile cleanup** — Remove local path overrides; dependencies resolve from gemspec via rubygems.
30
+ - **Dependency bump** — Require `lex-llm >= 0.5.0` for canonical types support.
31
+ - **Canonical tool support** — Use `ToolSchema.extract` and add `:tools` capability.
32
+ - **Bug fix** — Handle Array tool_calls in `tool_call_parts`.
33
+ - 29 examples, 0 failures; 13 files, 0 rubocop offenses.
34
+
3
35
  ## 0.2.10 - 2026-06-02
4
36
 
5
37
  - 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
@@ -16,7 +24,11 @@ module Legion
16
24
  class DiscoveryRefresh < Legion::Extensions::Actors::Every # rubocop:disable Style/Documentation
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,16 +38,106 @@ 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, :vertex, :discovery_interval) || REFRESH_INTERVAL
43
+ Legion::Settings.dig(:extensions, :llm, :vertex, :discovery_interval) || self.class.every_seconds
32
44
  end
33
45
 
34
- def manual
35
- log.debug('[vertex][discovery_refresh] refreshing model list')
36
- return unless defined?(Legion::LLM::Discovery)
46
+ def scope_key
47
+ { provider: :vertex }
48
+ end
49
+
50
+ def compute_lanes_for_scope(**)
51
+ return [] unless defined?(Legion::LLM::Call::Registry)
52
+
53
+ settings = Legion::Settings.dig(:extensions, :llm, :vertex) || {}
54
+ fleet_enabled = settings.dig(:fleet, :dispatch, :enabled)
55
+ instances = Legion::LLM::Call::Registry.all_instances.select do |e|
56
+ (e[:provider] || '').to_sym == :vertex
57
+ end
37
58
 
38
- Legion::LLM::Discovery.refresh_discovered_models!(provider: :vertex)
59
+ instances.flat_map do |entry|
60
+ lanes_for_instance(entry, fleet_enabled: fleet_enabled)
61
+ end
62
+ rescue StandardError => e
63
+ handle_exception(e, level: :warn, handled: true, operation: 'vertex.actor.compute_lanes_for_scope')
64
+ []
65
+ end
66
+
67
+ private
68
+
69
+ def lanes_for_instance(entry, fleet_enabled: false)
70
+ adapter = entry[:adapter]
71
+ instance_id = entry[:instance] || entry[:instance_id] || entry[:id]
72
+ lanes = []
73
+ Array(adapter.discover_offerings(live: false)).each do |raw_offering|
74
+ offering = offering_to_hash(raw_offering)
75
+ next unless offering
76
+
77
+ lane = build_lane(offering, instance_id)
78
+ lanes << lane
79
+ lanes << fleet_lane(lane, instance_id, offering) if fleet_enabled && lane[:type] == :inference
80
+ end
81
+ lanes
82
+ end
83
+
84
+ def offering_to_hash(offering)
85
+ return nil if offering.nil?
86
+ return offering if offering.is_a?(Hash)
87
+
88
+ hash = offering.to_h
89
+ hash[:type] ||= hash[:usage_type]
90
+ hash[:enabled] = offering.respond_to?(:enabled?) ? offering.enabled? : true
91
+ hash
92
+ end
93
+
94
+ def build_lane(offering, instance_id)
95
+ type = offering_type(offering)
96
+ tier = offering[:tier]&.to_sym || :cloud
97
+ caps = normalize_capabilities(offering[:capabilities])
98
+ flds = { tier: tier, provider_family: :vertex, instance_id: instance_id,
99
+ type: type, model: offering[:model] }
100
+ {
101
+ id: Legion::Extensions::Llm::Inventory::ScopedRefresher.compose_id(flds),
102
+ tier: tier,
103
+ provider_family: :vertex,
104
+ instance_id: instance_id,
105
+ model: offering[:model],
106
+ canonical_model_alias: offering[:canonical_model_alias],
107
+ type: type,
108
+ capabilities: caps,
109
+ limits: offering[:limits] || {},
110
+ enabled: offering.fetch(:enabled, true),
111
+ cost: offering[:cost] || {}
112
+ }
113
+ end
114
+
115
+ def fleet_lane(lane, instance_id, offering)
116
+ flds = { tier: :fleet, provider_family: :vertex, instance_id: instance_id,
117
+ type: lane[:type], model: offering[:model] }
118
+ lane.merge(id: Legion::Extensions::Llm::Inventory::ScopedRefresher.compose_id(flds), tier: :fleet)
119
+ end
120
+
121
+ def offering_type(offering)
122
+ %i[embed embedding].include?(offering[:type]&.to_sym) ? :embedding : :inference
123
+ end
124
+
125
+ def normalize_capabilities(caps)
126
+ return [] unless defined?(Legion::Extensions::Llm::Inventory::Capabilities) &&
127
+ Legion::Extensions::Llm::Inventory::Capabilities.respond_to?(:normalize)
128
+
129
+ Legion::Extensions::Llm::Inventory::Capabilities.normalize(caps)
130
+ end
131
+
132
+ public
133
+
134
+ def credential_hash(**)
135
+ settings = Legion::Settings.dig(:extensions, :llm, :vertex) || {}
136
+ ::Digest::SHA256.hexdigest(settings[:api_key].to_s + settings[:instances].to_s)[0, 16]
137
+ end
138
+
139
+ def manual
140
+ tick if respond_to?(:tick)
39
141
  rescue StandardError => e
40
142
  handle_exception(e, level: :warn, handled: true, operation: 'vertex.actor.discovery_refresh')
41
143
  end
@@ -116,7 +116,7 @@ module Legion
116
116
  "#{publisher_model_path(model)}:#{suffix}"
117
117
  end
118
118
 
119
- def list_models(**)
119
+ def list_models(**_filters)
120
120
  log.info { 'listing available Vertex models from static catalog' }
121
121
  STATIC_MODELS.map { |entry| model_info_from_static(entry) }.tap do |models|
122
122
  log.info { "discovered #{models.size} Vertex model(s); publishing to registry" }
@@ -125,22 +125,13 @@ module Legion
125
125
  end
126
126
 
127
127
  def discover_offerings(live: false, **filters)
128
- log.info { "discovering offerings live=#{live} project=#{project} location=#{location}" }
129
- return static_offerings(**filters) unless live
130
-
131
- response = connection.get(models_url)
132
- models = response.body['publisherModels'] || response.body['models'] || []
133
- offerings = models.filter_map do |model|
134
- offering = offering_from_live_model(model)
135
- model_id = offering.respond_to?(:model) ? offering.model : (offering[:model] || offering[:id])
136
- next unless model_allowed?(model_id.to_s)
137
-
138
- offering
128
+ unless live
129
+ return static_offerings(**filters).select do |offering|
130
+ model_allowed?(short_model_id(offering.model))
131
+ end
139
132
  end
140
- log.info { "discovered #{offerings.size} live offering(s) from Vertex" }
141
- model_infos = offerings.map { |o| model_info_from_offering(o) }
142
- self.class.registry_publisher.publish_models_async(model_infos, readiness: readiness(live: false))
143
- offerings
133
+
134
+ super
144
135
  end
145
136
 
146
137
  def offering_for(model:, model_family: nil, instance_id: :default, **metadata)
@@ -325,8 +316,50 @@ module Legion
325
316
  offering_for(model: id, publisher:, metadata: model)
326
317
  end
327
318
 
319
+ def offering_from_model(model_info, health: {})
320
+ metadata = model_info.respond_to?(:metadata) ? model_info.metadata.to_h : {}
321
+ raw_model = model_info.respond_to?(:id) ? model_info.id : model_info
322
+ publisher = metadata[:publisher] || metadata['publisher'] || publisher_for(raw_model)
323
+ api = metadata[:api] || metadata['api'] || api_for(raw_model)
324
+ alias_name = model_info.respond_to?(:name) ? model_info.name : nil
325
+ alias_name = nil if alias_name.to_s.empty? || alias_name.to_s == raw_model.to_s
326
+
327
+ build_offering(
328
+ model: resource_name(raw_model, publisher: publisher),
329
+ alias_name: alias_name,
330
+ model_family: if model_info.respond_to?(:family) && model_info.family
331
+ model_info.family.to_sym
332
+ else
333
+ model_family_for(
334
+ raw_model, publisher
335
+ )
336
+ end,
337
+ instance_id: if model_info.respond_to?(:instance)
338
+ model_info.instance || provider_instance_id
339
+ else
340
+ provider_instance_id
341
+ end,
342
+ publisher: publisher,
343
+ usage_type: if model_info.respond_to?(:embedding?) && model_info.embedding?
344
+ :embedding
345
+ else
346
+ usage_type_for(raw_model)
347
+ end,
348
+ api: api,
349
+ health: health,
350
+ metadata: metadata.merge(
351
+ limits: {
352
+ context_window: model_info.respond_to?(:context_length) ? model_info.context_length : nil,
353
+ max_output_tokens: model_info.respond_to?(:max_output_tokens) ? model_info.max_output_tokens : nil
354
+ }.compact
355
+ )
356
+ )
357
+ end
358
+
328
359
  def build_offering(model:, model_family:, usage_type:, publisher:, api:, instance_id: :default,
329
- alias_name: nil, metadata: {})
360
+ alias_name: nil, health: {}, metadata: {})
361
+ policy = resolve_capability_policy(model, api:, metadata:, instance_id:)
362
+
330
363
  Legion::Extensions::Llm::Routing::ModelOffering.new(
331
364
  provider_family: :vertex,
332
365
  instance_id: instance_id,
@@ -334,8 +367,10 @@ module Legion
334
367
  tier: offering_tier,
335
368
  model: model,
336
369
  usage_type: usage_type,
337
- capabilities: default_capabilities(model, api:),
370
+ capabilities: base_capabilities(model, api:) + policy[:capabilities],
371
+ capability_sources: policy[:sources],
338
372
  limits: metadata.delete(:limits) || {},
373
+ health: health,
339
374
  metadata: metadata.merge(
340
375
  model_family: model_family,
341
376
  alias: alias_name,
@@ -478,7 +513,9 @@ module Legion
478
513
  end
479
514
 
480
515
  def tool_call_parts(message)
481
- message.tool_calls.values.map do |tool_call|
516
+ # Array is canonical (name-keyed hashes dropped parallel same-name calls)
517
+ calls = message.tool_calls.is_a?(Hash) ? message.tool_calls.values : Array(message.tool_calls)
518
+ calls.map do |tool_call|
482
519
  { functionCall: { name: tool_call.name, args: tool_call.arguments } }
483
520
  end
484
521
  end
@@ -497,9 +534,11 @@ module Legion
497
534
 
498
535
  [{
499
536
  functionDeclarations: tools.values.map do |tool|
500
- declaration = { name: tool.name, description: tool.description }
501
- declaration[:parameters] = tool.params_schema if tool.respond_to?(:params_schema) && tool.params_schema
502
- declaration
537
+ {
538
+ name: Legion::Extensions::Llm::Canonical::ToolSchema.tool_name(tool),
539
+ description: Legion::Extensions::Llm::Canonical::ToolSchema.tool_description(tool),
540
+ parameters: Legion::Extensions::Llm::Canonical::ToolSchema.extract(tool)
541
+ }
503
542
  end
504
543
  }]
505
544
  end
@@ -623,15 +662,109 @@ module Legion
623
662
  end
624
663
 
625
664
  def default_capabilities(model, api:)
665
+ base_capabilities(model, api:) + policy_optional_capabilities(model, api:)
666
+ end
667
+
668
+ def base_capabilities(model, api:)
626
669
  return %i[embedding] if Capabilities.embeddings?(model)
627
670
 
628
671
  capabilities = %i[chat]
629
672
  capabilities << :streaming if %i[generate_content raw_predict].include?(api)
630
- capabilities << :vision if Capabilities.vision?(model)
631
- capabilities << :functions if generate_content_model?(model)
632
673
  capabilities
633
674
  end
634
675
 
676
+ def policy_optional_capabilities(model, api:)
677
+ return [] if Capabilities.embeddings?(model)
678
+
679
+ caps = []
680
+ caps << :vision if Capabilities.vision?(model)
681
+ caps << :tools if generate_content_model?(model) && api == :generate_content
682
+ caps
683
+ end
684
+
685
+ def resolve_capability_policy(model, api:, metadata:, instance_id:)
686
+ provider_catalog = capability_catalog_for(model, api:)
687
+ real_caps = capability_real_for(metadata)
688
+ provider_cfg = vertex_provider_config
689
+ instance_cfg = vertex_instance_config(instance_id)
690
+ model_cfg = vertex_model_config(model)
691
+
692
+ Legion::Extensions::Llm::CapabilityPolicy.resolve(
693
+ real: real_caps,
694
+ provider_catalog: provider_catalog,
695
+ probe: {},
696
+ provider_envelope: {},
697
+ provider_config: provider_cfg,
698
+ instance_config: instance_cfg,
699
+ model_config: model_cfg
700
+ )
701
+ end
702
+
703
+ def capability_catalog_for(model, api:)
704
+ return {} if Capabilities.embeddings?(model)
705
+
706
+ catalog = {}
707
+ catalog[:vision] = Capabilities.vision?(model)
708
+ catalog[:tools] = api == :generate_content
709
+ catalog[:streaming] = %i[generate_content raw_predict].include?(api)
710
+ catalog
711
+ end
712
+
713
+ def capability_real_for(metadata)
714
+ return {} unless metadata.is_a?(Hash)
715
+
716
+ features = metadata[:supportedFeatures] || metadata['supportedFeatures']
717
+ return {} unless features.is_a?(Hash)
718
+
719
+ real = {}
720
+ real[:tools] = features['functionCalling'] if features.key?('functionCalling')
721
+ real[:vision] = features['multimodalInput'] if features.key?('multimodalInput')
722
+ real[:thinking] = features['thinking'] if features.key?('thinking')
723
+ real
724
+ end
725
+
726
+ def vertex_provider_config
727
+ cfg = CredentialSources.setting(:extensions, :llm, :vertex)
728
+ return {} unless cfg.is_a?(Hash)
729
+
730
+ cfg.except(:instances, 'instances')
731
+ rescue StandardError => e
732
+ handle_exception(e, level: :warn, handled: true, operation: 'vertex.provider.capability_policy_config')
733
+ {}
734
+ end
735
+
736
+ def vertex_instance_config(instance_id)
737
+ cfg = CredentialSources.setting(:extensions, :llm, :vertex)
738
+ return {} unless cfg.is_a?(Hash)
739
+
740
+ instances = cfg[:instances] || cfg['instances']
741
+ return {} unless instances.is_a?(Hash)
742
+
743
+ (instances[instance_id] || instances[instance_id.to_s] || {}).to_h
744
+ rescue StandardError => e
745
+ handle_exception(e, level: :warn, handled: true, operation: 'vertex.provider.instance_config')
746
+ {}
747
+ end
748
+
749
+ def vertex_model_config(model)
750
+ cfg = CredentialSources.setting(:extensions, :llm, :vertex)
751
+ return {} unless cfg.is_a?(Hash)
752
+
753
+ models = cfg[:models] || cfg['models']
754
+ return {} unless models.is_a?(Hash)
755
+
756
+ id = short_model_id(model)
757
+ (models[id.to_sym] || models[id.to_s] || {}).to_h
758
+ rescue StandardError => e
759
+ handle_exception(e, level: :warn, handled: true, operation: 'vertex.provider.model_config')
760
+ {}
761
+ end
762
+
763
+ def short_model_id(model)
764
+ id = model_id(model)
765
+ id.include?('/') ? id.split('/').last : id
766
+ end
767
+
635
768
  def bearer_token
636
769
  token = config.vertex_access_token
637
770
  token ? "Bearer #{token}" : nil
@@ -4,7 +4,7 @@ module Legion
4
4
  module Extensions
5
5
  module Llm
6
6
  module Vertex
7
- VERSION = '0.2.10'
7
+ VERSION = '0.2.15'
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/vertex/provider'
5
5
  require 'legion/extensions/llm/vertex/version'
6
+ require_relative 'vertex/actors/discovery_refresh'
6
7
 
7
8
  module Legion
8
9
  module Extensions
@@ -39,10 +40,7 @@ module Legion
39
40
  fleet: {
40
41
  enabled: false,
41
42
  respond_to_requests: false,
42
- capabilities: %i[chat stream_chat embed],
43
- lanes: [],
44
- concurrency: 4,
45
- queue_suffix: nil
43
+ capabilities: %i[chat stream_chat embed tools]
46
44
  }
47
45
  }
48
46
  )
@@ -103,12 +101,7 @@ module Legion
103
101
  end
104
102
 
105
103
  def self.register_provider_options
106
- configuration = Legion::Extensions::Llm::Configuration
107
- if configuration.respond_to?(:register_provider_options)
108
- configuration.register_provider_options(Provider.configuration_options)
109
- elsif configuration.respond_to?(:option, true)
110
- Provider.configuration_options.each { |key| configuration.send(:option, key) }
111
- end
104
+ Legion::Extensions::Llm::Configuration.register_provider_options(Provider.configuration_options)
112
105
  end
113
106
 
114
107
  private_class_method :discover_default_instance, :discover_named_instances, :vertex_credentials_present?,
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lex-llm-vertex
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.10
4
+ version: 0.2.15
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: Google Cloud Vertex AI provider integration for the LegionIO LLM routing
83
83
  framework.
84
84
  email: