lex-llm-gemini 0.4.4 → 0.4.5

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: b3524657be64163617964f55cfeb0f375536587ebb79f1d118d8b7b53032b59d
4
- data.tar.gz: f80938bffd0e0f8794a0de0cc7857064cf1db3c19c948042acdfb423da63d9ec
3
+ metadata.gz: 4fc6c5255eae71b411f35c57a052ac77bb3d9aecdb2855672472f8b1a4f4f514
4
+ data.tar.gz: af8645870657d5f743b398c88f96cb2a74cb849eb15afa3553b723c0d02c2f58
5
5
  SHA512:
6
- metadata.gz: 42f5f91e84ca2b380f983dbe04d4f4ff1171cf06c4aa14cf4b884e219700869c19e85b57c39f90e57bbca81f9b9b490a9e11dab2bff5641d55b0e03bfc02ea3b
7
- data.tar.gz: a70c14d20367d1e7b2dfe57647cfa1a56f235396087707a84759732e408b6b37aa366cde5e2ee5957fdcce7e252ac9f3ec45d8c255d95de821a53c8447a99ba2
6
+ metadata.gz: cbdc6a0ff9351f061590beedecfaeba3ccd78666aa97db675e9ff2a528834a22ba5542afd3b63bb143f8d34cd7756b0bc3719a48a84cc4423997d9dd0e07fb3e
7
+ data.tar.gz: 5cfcce92073c6e27a86e20b5200ae0b0babf5eec1061c20c43928c9dd34e63b0fce8311f57d7637ca4d70d49c6134d69b5b45950e415604ad3ee77e6597097c9
data/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.4.5] - 2026-08-19
4
+
5
+ ### Changed
6
+ - Publish the validated four-axis lane weight pair on every Gemini offering and reconcile weight-only changes atomically on the existing discovery cadence.
7
+ - Track initializing instances before readiness, rebuild weights from current settings at activation, and serialize activation, replacement, removal, and cache mutation behind one writer mutex.
8
+
9
+ ### Fixed
10
+ - Report configured-but-unpublished Gemini weight keys once per dormant cycle without adding a settings callback or operator workflow.
11
+ - Render canonical system messages through the actual Gemini callable path as native `systemInstruction` payloads.
12
+ - Validate weighted offering drafts before constructing or claiming a callable scope, so malformed weights leave no orphaned Registry publication and a later corrected ordinary pass activates without restart or operator cleanup.
13
+ - Compare rebuilt catalogs as duplicate-preserving multisets of complete offering contracts, ignoring only evidence observation timestamps so catalog reordering cannot churn snapshots and authoritative metadata, quota, publication-source, or evidence-source changes cannot be missed.
14
+
15
+ ### Dependencies
16
+ - Raise the `lex-llm` floor from `>= 0.7.1` to `>= 0.7.6`; the `legion-settings` floor remains unchanged.
17
+
3
18
  ## [0.4.4] - 2026-08-18
4
19
 
5
20
  ### Fixed
@@ -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.4.2'
29
29
  spec.add_dependency 'legion-transport', '>= 1.4.14'
30
- spec.add_dependency 'lex-llm', '>= 0.7.1'
30
+ spec.add_dependency 'lex-llm', '>= 0.7.6'
31
31
  end
@@ -23,6 +23,7 @@ require 'legion/extensions/llm/inventory/identity'
23
23
  require 'legion/extensions/llm/inventory/records'
24
24
  require 'legion/extensions/llm/inventory/evidence'
25
25
  require 'legion/extensions/llm/inventory/probe_coordinator'
26
+ require 'legion/extensions/llm/inventory/weight_reconciler'
26
27
  require 'legion/extensions/llm/routing/provider_outcome'
27
28
  require 'legion/extensions/llm/taxonomies'
28
29
  require 'legion/extensions/llm/capabilities'
@@ -212,9 +213,18 @@ module Legion
212
213
  def build_offering_draft(model_id:, model_data:, instance_cfg:, instance_key:)
213
214
  tier = instance_cfg[:tier] || :frontier
214
215
  generation_methods = extract_generation_methods(model_data: model_data)
216
+ weight_inputs = Legion::Extensions::Llm::Inventory::WeightSchema.weight_inputs(
217
+ settings: Legion::Settings,
218
+ instance_key: instance_key,
219
+ provider_native_key: model_id,
220
+ model: model_id,
221
+ tier: tier
222
+ )
215
223
 
216
224
  Legion::Extensions::Llm::Inventory::OfferingDraft.new(
217
225
  provider_native_key: model_id, model: model_id, tier: tier,
226
+ weight_inputs: weight_inputs,
227
+ base_weight: Legion::Extensions::Llm::Inventory::WeightSchema.base_weight(weight_inputs),
218
228
  operation_evidence: build_operation_evidence(generation_methods: generation_methods),
219
229
  capability_evidence: build_capability_evidence(generation_methods: generation_methods),
220
230
  context_evidence: build_context_evidence(model_data: model_data),
@@ -432,7 +442,7 @@ module Legion
432
442
  end
433
443
 
434
444
  def handle_reactive_probe(instance_id:, request:)
435
- state = @instance_states[instance_id]
445
+ state = tracked_instance_state(instance_id)
436
446
  return unless state
437
447
 
438
448
  coordinator = state[:probe_coordinator]
@@ -537,52 +547,40 @@ module Legion
537
547
  # ── Instance lifecycle helpers ───────────────────────────────────────
538
548
  # ── Offering change comparison helpers ───────────────────────────────
539
549
  module OfferingComparison
550
+ SCALAR_EVIDENCE_FIELDS = %i[
551
+ context_evidence max_output_evidence embedding_dimensions_evidence
552
+ model_revision_evidence tokenizer_evidence
553
+ ].freeze
554
+
540
555
  private
541
556
 
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).
557
+ # Every catalog pass rebuilds evidence observation timestamps. The
558
+ # timestamps are telemetry, but every other draft field remains
559
+ # authoritative. Catalog order is not authoritative; multiplicity is.
546
560
  def offerings_changed?(previous:, current:)
547
- current.map { |draft| offering_signature(draft) } !=
548
- previous.map { |draft| offering_signature(draft) }
561
+ offering_comparison_multiset(current) != offering_comparison_multiset(previous)
549
562
  end
550
563
 
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
- ]
564
+ def offering_comparison_multiset(offerings)
565
+ Array(offerings).map { |draft| offering_comparison_state(draft) }.tally
560
566
  end
561
567
 
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
568
+ def offering_comparison_state(draft)
569
+ state = draft.to_h
570
+ state[:operation_evidence] = comparison_evidence_map(draft.operation_evidence)
571
+ state[:capability_evidence] = comparison_evidence_map(draft.capability_evidence)
572
+ SCALAR_EVIDENCE_FIELDS.each do |field|
573
+ state[field] = comparison_evidence(draft.public_send(field))
574
+ end
575
+ state
572
576
  end
573
577
 
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
- ]
578
+ def comparison_evidence_map(evidence)
579
+ evidence.transform_values { |entry| comparison_evidence(entry) }
582
580
  end
583
581
 
584
- def value_pair(evidence)
585
- [evidence.status, evidence.value]
582
+ def comparison_evidence(evidence)
583
+ evidence.to_h.except(:observed_at)
586
584
  end
587
585
  end
588
586
 
@@ -696,9 +694,9 @@ module Legion
696
694
  instance_id = name.to_s
697
695
  physical_id = derive_physical_id(instance_cfg: instance_cfg)
698
696
  instance_key = build_instance_key(instance_id: instance_id, physical_id: physical_id)
697
+ offerings = discover_offerings_for_instance(instance_cfg: instance_cfg, instance_key: instance_key)
699
698
  components = build_instance_components(instance_id: instance_id, instance_cfg: instance_cfg,
700
699
  instance_key: instance_key)
701
- offerings = discover_offerings_for_instance(instance_cfg: instance_cfg, instance_key: instance_key)
702
700
  state = {
703
701
  name: name,
704
702
  instance_id: instance_id,
@@ -708,24 +706,101 @@ module Legion
708
706
  probe_coordinator: components[:probe_coordinator],
709
707
  publisher_token: components[:publisher_token],
710
708
  sequence: 0,
711
- offerings: offerings
709
+ offerings: offerings,
710
+ published: false
712
711
  }
712
+ Legion::Extensions::Llm::Inventory::WeightReconciler.track_initializing!(
713
+ states: @instance_states, state_key: instance_id, state: state, mutex: @instance_state_mutex
714
+ )
713
715
  settle_initial_readiness(instance_id: instance_id, state: state)
714
- @instance_states[instance_id] = state
715
- sync_display_health(state: state)
716
+ sync_display_health(state: state) if tracked_instance_state(instance_id).equal?(state)
716
717
  end
717
718
 
718
719
  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
- )
720
+ removed = @instance_state_mutex.synchronize do
721
+ next false unless @instance_states[instance_id].equal?(state)
722
+
723
+ publisher.remove_instance(
724
+ instance_id: instance_id, publisher_token: state[:publisher_token],
725
+ physical_id: state[:instance_key].physical_id
726
+ )
727
+ @instance_states.delete(instance_id)
728
+ true
729
+ end
730
+ return false unless removed
731
+
723
732
  state[:callable]&.disconnect
724
733
  clear_display_health(name: state[:name])
725
- @instance_states.delete(instance_id)
734
+ true
726
735
  rescue StandardError => e
727
736
  handle_exception(e, level: :warn, operation: 'gemini.actor.remove_instance',
728
737
  instance_id: instance_id)
738
+ false
739
+ end
740
+ end
741
+
742
+ # Shared weight-reconciliation bindings and synchronized state access.
743
+ module WeightPublication
744
+ private
745
+
746
+ def commit_discovered_offerings(instance_id:, state:, offerings:)
747
+ Legion::Extensions::Llm::Inventory::WeightReconciler.commit_if_changed!(
748
+ settings: Legion::Settings,
749
+ instance_id: instance_id,
750
+ state: state,
751
+ discovered_offerings: offerings,
752
+ mutex: @instance_state_mutex,
753
+ equivalent: lambda do |previous, current|
754
+ !offerings_changed?(previous: previous, current: current)
755
+ end,
756
+ replace: method(:replace_weight_snapshot)
757
+ )
758
+ end
759
+
760
+ def replace_weight_snapshot(instance_id:, state:, offerings:, sequence:)
761
+ publisher.replace_instance_snapshot(
762
+ instance_id: instance_id,
763
+ publisher_token: state.fetch(:publisher_token),
764
+ offerings: offerings,
765
+ sequence: sequence,
766
+ physical_id: state.fetch(:instance_key).physical_id
767
+ )
768
+ end
769
+
770
+ def activate_weight_snapshot(instance_id:, state:, offerings:, sequence:, probe_token:)
771
+ publisher.activate_instance_snapshot(
772
+ instance_id: instance_id,
773
+ publisher_token: state.fetch(:publisher_token),
774
+ offerings: offerings,
775
+ sequence: sequence,
776
+ probe_token: probe_token,
777
+ physical_id: state.fetch(:instance_key).physical_id
778
+ )
779
+ end
780
+
781
+ def tracked_instance_state(instance_id)
782
+ return unless @instance_states && @instance_state_mutex
783
+
784
+ @instance_state_mutex.synchronize { @instance_states[instance_id] }
785
+ end
786
+
787
+ def instance_states_snapshot
788
+ @instance_state_mutex.synchronize { @instance_states.each_pair.to_h }
789
+ end
790
+
791
+ def observe_dormant_weights
792
+ Legion::Extensions::Llm::Inventory::WeightReconciler.observe_dormant!(
793
+ settings: Legion::Settings,
794
+ provider_family: :gemini,
795
+ states: @instance_states,
796
+ mutex: @instance_state_mutex,
797
+ tracker: @dormant_weight_tracker,
798
+ dormant_logger: lambda do |key|
799
+ log.info do
800
+ "[llm][gemini] action=dormant_weight weight_key=#{key.inspect} no_lane_published=true"
801
+ end
802
+ end
803
+ )
729
804
  end
730
805
  end
731
806
 
@@ -738,6 +813,8 @@ module Legion
738
813
 
739
814
  def initial_discovery
740
815
  @instance_states = Concurrent::Map.new
816
+ @instance_state_mutex = Mutex.new
817
+ @dormant_weight_tracker = Legion::Extensions::Llm::Inventory::DormantWeightTracker.new
741
818
  reconcile_and_refresh
742
819
  end
743
820
 
@@ -750,10 +827,10 @@ module Legion
750
827
  # probe just ran; refresh and cadence probes start next tick.
751
828
  def reconcile_and_refresh
752
829
  configured = configured_instances
753
- existing = @instance_states.keys
830
+ existing = instance_states_snapshot.keys
754
831
  add_newly_configured_instances(configured: configured)
755
832
  remove_unconfigured_instances(configured: configured)
756
- @instance_states.each do |instance_id, state|
833
+ instance_states_snapshot.each do |instance_id, state|
757
834
  next unless existing.include?(instance_id)
758
835
 
759
836
  refresh_instance(instance_id: instance_id, state: state)
@@ -761,6 +838,7 @@ module Legion
761
838
  handle_exception(e, level: :warn, operation: 'gemini.actor.refresh_instance',
762
839
  instance_id: instance_id)
763
840
  end
841
+ observe_dormant_weights
764
842
  end
765
843
 
766
844
  def add_newly_configured_instances(configured:)
@@ -769,7 +847,7 @@ module Legion
769
847
  # id: two config names pointing at the same endpoint are
770
848
  # distinct instances (the physical id never participates in
771
849
  # identity).
772
- next if @instance_states.key?(name.to_s)
850
+ next if tracked_instance_state(name.to_s)
773
851
 
774
852
  claim_and_activate_instance(name: name, instance_cfg: instance_cfg)
775
853
  rescue StandardError => e
@@ -778,7 +856,7 @@ module Legion
778
856
  end
779
857
 
780
858
  def remove_unconfigured_instances(configured:)
781
- @instance_states.each_value do |state|
859
+ instance_states_snapshot.each_value do |state|
782
860
  next if configured.key?(state[:name])
783
861
 
784
862
  drop_instance(instance_id: state[:instance_id], state: state)
@@ -796,9 +874,16 @@ module Legion
796
874
  )
797
875
  readiness = check_health(instance_cfg: state[:instance_cfg])
798
876
  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
877
+ Legion::Extensions::Llm::Inventory::WeightReconciler.activate_tracked!(
878
+ settings: Legion::Settings,
879
+ instance_id: instance_id,
880
+ state_key: instance_id,
881
+ state: state,
882
+ states: @instance_states,
883
+ mutex: @instance_state_mutex,
884
+ probe_token: probe_token,
885
+ activate: method(:activate_weight_snapshot),
886
+ activation_sequence: ->(tracked) { tracked.fetch(:sequence) }
802
887
  )
803
888
  else
804
889
  publisher.readiness_failed(
@@ -827,36 +912,34 @@ module Legion
827
912
  # path a transient outage at boot pins the instance for the process
828
913
  # lifetime. Re-probe each tick and activate once readiness passes.
829
914
  def retry_initial_activation(instance_id:, state:)
830
- state[:offerings] = discover_offerings_for_instance(
915
+ offerings = discover_offerings_for_instance(
831
916
  instance_cfg: state[:instance_cfg], instance_key: state[:instance_key]
832
917
  )
918
+ commit_discovered_offerings(instance_id: instance_id, state: state, offerings: offerings)
833
919
  settle_initial_readiness(instance_id: instance_id, state: state)
834
- sync_display_health(state: state)
920
+ sync_display_health(state: state) if tracked_instance_state(instance_id).equal?(state)
835
921
  end
836
922
 
837
923
  def replace_offerings_if_changed(instance_id:, state:)
838
924
  new_offerings = discover_offerings_for_instance(
839
925
  instance_cfg: state[:instance_cfg], instance_key: state[:instance_key]
840
926
  )
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
927
+ changed = commit_discovered_offerings(
928
+ instance_id: instance_id, state: state, offerings: new_offerings
848
929
  )
849
- state[:offerings] = new_offerings
850
- sync_display_health(state: state)
930
+ sync_display_health(state: state) if changed
851
931
  end
852
932
 
853
933
  def remove_all_instances
854
934
  return unless @instance_states
855
935
 
856
- @instance_states.each_value do |state|
936
+ instance_states_snapshot.each_value do |state|
857
937
  drop_instance(instance_id: state[:instance_id], state: state)
858
938
  end
859
- @instance_states.clear
939
+ @instance_state_mutex.synchronize do
940
+ @instance_states.clear
941
+ @dormant_weight_tracker.clear!
942
+ end
860
943
  end
861
944
  end
862
945
 
@@ -880,6 +963,7 @@ module Legion
880
963
  include OfferingComparison
881
964
  include DisplayHealth
882
965
  include InstanceComponents
966
+ include WeightPublication
883
967
  include InstanceLifecycle
884
968
 
885
969
  def runner_class = self.class
@@ -6,6 +6,25 @@ module Legion
6
6
  module Extensions
7
7
  module Llm
8
8
  module Gemini
9
+ # Message-kind compatibility across legacy and Canonical message types.
10
+ module MessageKinds
11
+ private
12
+
13
+ def tool_call_message?(message)
14
+ return message.tool_call? if message.respond_to?(:tool_call?)
15
+
16
+ calls = message.tool_calls
17
+ !calls.nil? && !calls.empty?
18
+ end
19
+
20
+ def tool_result_message?(message)
21
+ return message.tool_result? if message.respond_to?(:tool_result?)
22
+
23
+ tool_call_id = message.tool_call_id
24
+ !tool_call_id.nil? && !tool_call_id.to_s.empty?
25
+ end
26
+ end
27
+
9
28
  # Message formatting helpers mixed into Provider.
10
29
  module MessageFormatter
11
30
  private
@@ -57,14 +76,14 @@ module Legion
57
76
 
58
77
  def gemini_role(message)
59
78
  return 'model' if message.role == :assistant
60
- return 'function' if message.tool_result?
79
+ return 'function' if tool_result_message?(message)
61
80
 
62
81
  message.role.to_s
63
82
  end
64
83
 
65
84
  def message_parts(message)
66
- return tool_call_parts(message) if message.tool_call?
67
- return tool_result_parts(message) if message.tool_result?
85
+ return tool_call_parts(message) if tool_call_message?(message)
86
+ return tool_result_parts(message) if tool_result_message?(message)
68
87
 
69
88
  content_parts(message.content)
70
89
  end
@@ -327,6 +346,7 @@ module Legion
327
346
  # Gemini provider implementation for the Legion::Extensions::Llm base provider contract.
328
347
  class Provider < Legion::Extensions::Llm::Provider
329
348
  include Legion::Logging::Helper
349
+ include MessageKinds
330
350
  include MessageFormatter
331
351
  include ResponseParser
332
352
  include OfferingBuilder
@@ -4,7 +4,7 @@ module Legion
4
4
  module Extensions
5
5
  module Llm
6
6
  module Gemini
7
- VERSION = '0.4.4'
7
+ VERSION = '0.4.5'
8
8
  end
9
9
  end
10
10
  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.4.4
4
+ version: 0.4.5
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.7.1
74
+ version: 0.7.6
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.7.1
81
+ version: 0.7.6
82
82
  description: Gemini provider integration for the LegionIO LLM routing framework.
83
83
  email:
84
84
  - matthewdiverson@gmail.com