lex-llm-ollama 0.3.4 → 0.3.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: a7cb2a1511c933e0174c414ba400ef0d7fcc700feeb2ac866be66004447f9df9
4
- data.tar.gz: 90756cc1de56697e25880b801a07016a6e0d6b5edda22025b053850716e267cf
3
+ metadata.gz: c00ff8929f8723127315d78b2141144a64319b2af41526098397717f4833568a
4
+ data.tar.gz: 2bc5494b6cdd8ec0f4268218e530719baeadd75126c71f70bdbbc96f4e4fc468
5
5
  SHA512:
6
- metadata.gz: 603aee402d7b5f0733369fb78c45430ac452ae33d2f8cf05adc7871552ba19f556cd43ab9cf3986ad8a9644798fd0924b394c5d39aff4150ffcc5714003ec299
7
- data.tar.gz: 27a834f6be3252033fcc7f8028d8fd44f2056202bb7adf441f01b34f35832791aa6ac69c55db782398a509255af45f6c0ede82733d21dc7b19aa166683590b53
6
+ metadata.gz: b3a2b92564e059f37584612350f1e67a1f260d224b3237feaa8ba3158d4cef7d741765930dbd6a3d34694fcb7d8b1ce50ec43da743874a558134fbe32a9ad454
7
+ data.tar.gz: 6f79d61f8fd32e268d1363c21a78209f41a78560d8f22f8a60cfe0d5c35f97a3987e0900fb111b99b5721fe23d94bc9842f4132f86a3fe64c2f828c10c38984f
data/CHANGELOG.md CHANGED
@@ -3,6 +3,10 @@
3
3
  ## [Unreleased]
4
4
 
5
5
  ### Fixed
6
+ - **Complete discovery change detection** — ordinary discovery now compares the complete stable `OfferingDraft#to_h` contract as an order-independent, duplicate-count-preserving multiset. Only evidence `observed_at` telemetry is excluded; quota domains, metadata, publication source, scalar evidence authority, and every other draft field trigger one atomic replacement when changed.
7
+ - **Startup claim atomicity** — initial model discovery and write-time weight validation now complete before Ollama constructs a callable, coordinator, or Registry claim, so malformed settings cannot leave an orphan `:initializing` publication status or token; the next corrected discovery pass claims and activates the instance exactly once without a restart or operator repair.
8
+ - **SSOT v3 write-time weights** — Ollama drafts now publish the validated four-axis weight pair from live settings, and the existing discovery cadence atomically reconciles weight-only changes, initialization/activation races, sequence/cache updates, removals, and dormant configured-weight logging through lex-llm 0.7.6. No Settings callback, reload, reset, migration, restart, or operator action is introduced.
9
+ - **D14 callable-path wire conformance** — the actual `OllamaCallable#chat` path now accepts Task-01 canonical folded messages and renders the authoritative system content as the leading Ollama `role: system` message; legacy message tool-result handling remains intact.
6
10
  - Remove synthetic-default discovery filtering and its once-per-boot warning; configured discovery now passes every instance entry through normal enabled and endpoint validation.
7
11
  - **D1 Callable dispatch** — `OllamaCallable` now implements the fleet dispatch ops (`chat`, `stream_chat`, `embed`, `count_tokens`) by delegating to a per-instance `Ollama::Provider` (previously hardcoded fake payloads that swallowed `messages:` into `**`); errors propagate unrescued for `normalize_dispatch_error`; `disconnect` closes the Provider. Optional `provider:` injection seam for specs (production builds the real Provider lazily).
8
12
  - **D15 Raw-string model at the dispatch boundary** — the fleet passes `model:` as the offering's raw id (String). Ollama's render path is string-tolerant (`model.respond_to?(:id) ? model.id : model`) for chat and embed, embed places the model verbatim in the `Embedding` response object, and count_tokens ignores it — so the raw string passes through UNWRAPPED on every op (wrapping would serialize a `Data` object into the wire payload or the response object); `Model::Info` instances pass through unchanged.
@@ -27,7 +27,6 @@ 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
- # 0.7.1 carries the name-identity InstanceKey (instance_id = config name +
31
- # secondary physical_id field) that the SSOT v3 discovery actor publishes.
32
- spec.add_dependency 'lex-llm', '>= 0.7.1'
30
+ # 0.7.6 adds write-time WeightSchema records and the atomic writer reconciler.
31
+ spec.add_dependency 'lex-llm', '>= 0.7.6'
33
32
  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'
@@ -248,6 +249,13 @@ module Legion
248
249
  tier = instance_cfg[:tier] || :local
249
250
  detail = fetch_model_detail_safe(model_name: model_name, instance_cfg: instance_cfg)
250
251
  embed_supported = embedding_model?(model_name: model_name, model_data: model_data)
252
+ weight_inputs = Legion::Extensions::Llm::Inventory::WeightSchema.weight_inputs(
253
+ settings: Legion::Settings,
254
+ instance_key: instance_key,
255
+ provider_native_key: model_name,
256
+ model: model_name,
257
+ tier: tier
258
+ )
251
259
 
252
260
  Legion::Extensions::Llm::Inventory::OfferingDraft.new(
253
261
  provider_native_key: model_name,
@@ -267,7 +275,9 @@ module Legion
267
275
  quota_domains: {},
268
276
  metadata: build_offering_metadata(model_name: model_name, model_data: model_data)
269
277
  .merge(instance_id: instance_key.instance_id),
270
- publication_source: :provider_catalog
278
+ publication_source: :provider_catalog,
279
+ weight_inputs: weight_inputs,
280
+ base_weight: Legion::Extensions::Llm::Inventory::WeightSchema.base_weight(weight_inputs)
271
281
  )
272
282
  end
273
283
 
@@ -401,7 +411,7 @@ module Legion
401
411
  # name is the CONFIG NAME — the @instance_states key. The
402
412
  # publisher calls use the state's instance_id + physical_id pair.
403
413
  def handle_reactive_probe(name:, request:)
404
- state = @instance_states[name]
414
+ state = @state_mutex.synchronize { @instance_states[name] }
405
415
  return unless state
406
416
 
407
417
  coordinator = state[:probe_coordinator]
@@ -503,52 +513,40 @@ module Legion
503
513
 
504
514
  # ── Offering change comparison helpers ───────────────────────────────
505
515
  module OfferingComparison
516
+ SCALAR_EVIDENCE_FIELDS = %i[
517
+ context_evidence max_output_evidence embedding_dimensions_evidence
518
+ model_revision_evidence tokenizer_evidence
519
+ ].freeze
520
+
506
521
  private
507
522
 
508
- # Compare on identity and evidence status, not Data#==: every draft
509
- # embeds a fresh Time.now observed_at, so Data equality is false
510
- # across ticks even when the model set and capabilities are
511
- # unchanged (replace churn on every tick).
523
+ # Every draft embeds fresh evidence observed_at telemetry. Compare
524
+ # the complete stable draft contract as a multiset so catalog order
525
+ # is irrelevant while duplicate counts remain significant.
512
526
  def offerings_changed?(previous:, current:)
513
- current.map { |draft| offering_signature(draft) } !=
514
- previous.map { |draft| offering_signature(draft) }
527
+ offering_multiset(current) != offering_multiset(previous)
515
528
  end
516
529
 
517
- def offering_signature(draft)
518
- [
519
- draft.provider_native_key,
520
- draft.model,
521
- draft.tier,
522
- operation_signature(draft),
523
- capability_signature(draft),
524
- value_signature(draft)
525
- ]
530
+ def offering_multiset(offerings)
531
+ offerings.map { |draft| stable_offering_state(draft) }.tally
526
532
  end
527
533
 
528
- def operation_signature(draft)
529
- draft.operation_evidence.values.map do |evidence|
530
- [evidence.operation, evidence.status, evidence.source]
531
- end.sort
532
- end
533
-
534
- def capability_signature(draft)
535
- draft.capability_evidence.values.map do |evidence|
536
- [evidence.capability, evidence.status, evidence.source]
537
- end.sort
534
+ def stable_offering_state(draft)
535
+ state = draft.to_h
536
+ state[:operation_evidence] = stable_evidence_map(draft.operation_evidence)
537
+ state[:capability_evidence] = stable_evidence_map(draft.capability_evidence)
538
+ SCALAR_EVIDENCE_FIELDS.each do |field|
539
+ state[field] = stable_evidence(draft.public_send(field))
540
+ end
541
+ state
538
542
  end
539
543
 
540
- def value_signature(draft)
541
- [
542
- value_pair(draft.context_evidence),
543
- value_pair(draft.max_output_evidence),
544
- value_pair(draft.embedding_dimensions_evidence),
545
- value_pair(draft.model_revision_evidence),
546
- value_pair(draft.tokenizer_evidence)
547
- ]
544
+ def stable_evidence_map(evidence)
545
+ evidence.transform_values { |entry| stable_evidence(entry) }
548
546
  end
549
547
 
550
- def value_pair(evidence)
551
- [evidence.status, evidence.value]
548
+ def stable_evidence(evidence)
549
+ evidence.to_h.except(:observed_at)
552
550
  end
553
551
  end
554
552
 
@@ -668,11 +666,11 @@ module Legion
668
666
  instance_id = name.to_s
669
667
  physical_id = derive_physical_id(instance_cfg: instance_cfg)
670
668
  instance_key = build_instance_key(instance_id: instance_id, physical_id: physical_id)
669
+ offerings = discover_offerings_for_instance(instance_cfg: instance_cfg, instance_key: instance_key)
671
670
  components = build_instance_components(
672
671
  name: name.to_sym, instance_id: instance_id, physical_id: physical_id,
673
672
  instance_cfg: instance_cfg, instance_key: instance_key
674
673
  )
675
- offerings = discover_offerings_for_instance(instance_cfg: instance_cfg, instance_key: instance_key)
676
674
  state = {
677
675
  name: name.to_sym,
678
676
  instance_id: instance_id,
@@ -685,25 +683,113 @@ module Legion
685
683
  sequence: 0,
686
684
  offerings: offerings
687
685
  }
688
- settle_initial_readiness(state: state)
689
- @instance_states[name.to_sym] = state
690
- sync_display_health(state: state)
686
+ Legion::Extensions::Llm::Inventory::WeightReconciler.track_initializing!(
687
+ states: @instance_states,
688
+ state_key: name.to_sym,
689
+ state: state,
690
+ mutex: @state_mutex
691
+ )
692
+ settled = settle_initial_readiness(state: state)
693
+ sync_display_health(state: state) if settled
691
694
  end
692
695
 
693
696
  def drop_instance(name:, state:)
694
- publisher.remove_instance(
695
- instance_id: state[:instance_id], physical_id: state[:physical_id],
696
- publisher_token: state[:publisher_token]
697
- )
697
+ removed = @state_mutex.synchronize do
698
+ next false unless @instance_states[name].equal?(state)
699
+
700
+ publisher.remove_instance(
701
+ instance_id: state[:instance_id], physical_id: state[:physical_id],
702
+ publisher_token: state[:publisher_token]
703
+ )
704
+ @instance_states.delete(name)
705
+ true
706
+ end
707
+ return unless removed
708
+
698
709
  state[:callable]&.disconnect
699
710
  clear_display_health(name: state[:name])
700
- @instance_states.delete(name)
701
711
  rescue StandardError => e
702
712
  handle_exception(e, level: :warn, operation: 'ollama.actor.remove_instance',
703
713
  instance_id: state[:instance_id])
704
714
  end
705
715
  end
706
716
 
717
+ # Atomic adapters and dormant observation shared by the existing
718
+ # discovery cadence. Sequence and cache mutation remain owned by
719
+ # lex-llm's WeightReconciler.
720
+ module WeightPublication
721
+ private
722
+
723
+ def replace_offerings_if_changed(state:)
724
+ new_offerings = discover_offerings_for_instance(
725
+ instance_cfg: state[:instance_cfg], instance_key: state[:instance_key]
726
+ )
727
+ changed = Legion::Extensions::Llm::Inventory::WeightReconciler.commit_if_changed!(
728
+ settings: Legion::Settings,
729
+ instance_id: state[:instance_id],
730
+ state: state,
731
+ discovered_offerings: new_offerings,
732
+ mutex: @state_mutex,
733
+ equivalent: lambda do |previous, current|
734
+ !offerings_changed?(previous: previous, current: current)
735
+ end,
736
+ replace: method(:replace_weight_snapshot)
737
+ )
738
+ published = @state_mutex.synchronize { state[:published] }
739
+ sync_display_health(state: state) if changed && published
740
+ changed
741
+ end
742
+
743
+ def replace_weight_snapshot(instance_id:, state:, offerings:, sequence:)
744
+ publisher.replace_instance_snapshot(
745
+ instance_id: instance_id,
746
+ physical_id: state[:physical_id],
747
+ publisher_token: state.fetch(:publisher_token),
748
+ offerings: offerings,
749
+ sequence: sequence
750
+ )
751
+ end
752
+
753
+ def activate_weight_snapshot(instance_id:, state:, offerings:, sequence:, probe_token:)
754
+ publisher.activate_instance_snapshot(
755
+ instance_id: instance_id,
756
+ physical_id: state.fetch(:physical_id),
757
+ publisher_token: state.fetch(:publisher_token),
758
+ offerings: offerings,
759
+ sequence: sequence,
760
+ probe_token: probe_token
761
+ )
762
+ end
763
+
764
+ def observe_dormant_weights
765
+ Legion::Extensions::Llm::Inventory::WeightReconciler.observe_dormant!(
766
+ settings: Legion::Settings,
767
+ provider_family: :ollama,
768
+ states: @instance_states,
769
+ mutex: @state_mutex,
770
+ tracker: @dormant_weight_tracker,
771
+ dormant_logger: lambda do |key|
772
+ log.info do
773
+ "[llm][ollama] action=dormant_weight weight_key=#{key.inspect} no_lane_published=true"
774
+ end
775
+ end
776
+ )
777
+ end
778
+
779
+ def remove_all_instances
780
+ return unless @instance_states
781
+
782
+ states = @state_mutex.synchronize { @instance_states.each_pair.to_a }
783
+ states.each do |name, state|
784
+ drop_instance(name: name, state: state)
785
+ end
786
+ @state_mutex.synchronize do
787
+ @instance_states.clear
788
+ @dormant_weight_tracker.clear!
789
+ end
790
+ end
791
+ end
792
+
707
793
  # Per-instance SSOT lifecycle: reconcile configured instances each
708
794
  # tick, run the readiness state machine (initial probe, recovery
709
795
  # while :initializing, cadence probes, snapshot replacement), and
@@ -713,6 +799,8 @@ module Legion
713
799
 
714
800
  def initial_discovery
715
801
  @instance_states = Concurrent::Map.new
802
+ @state_mutex = Mutex.new
803
+ @dormant_weight_tracker = Legion::Extensions::Llm::Inventory::DormantWeightTracker.new
716
804
  reconcile_and_refresh
717
805
  end
718
806
 
@@ -728,10 +816,11 @@ module Legion
728
816
  # distinct, independently-managed instances.
729
817
  def reconcile_and_refresh
730
818
  configured = configured_instances
731
- existing = @instance_states.keys
819
+ existing = @state_mutex.synchronize { @instance_states.keys }
732
820
  add_newly_configured_instances(configured: configured)
733
821
  remove_unconfigured_instances(configured: configured)
734
- @instance_states.each do |name, state|
822
+ states = @state_mutex.synchronize { @instance_states.each_pair.to_a }
823
+ states.each do |name, state|
735
824
  next unless existing.include?(name)
736
825
 
737
826
  refresh_instance(state: state)
@@ -739,11 +828,13 @@ module Legion
739
828
  handle_exception(e, level: :warn, operation: 'ollama.actor.refresh_instance',
740
829
  instance_id: state[:instance_id])
741
830
  end
831
+ observe_dormant_weights
742
832
  end
743
833
 
744
834
  def add_newly_configured_instances(configured:)
745
835
  configured.each do |name, instance_cfg|
746
- next if @instance_states.key?(name.to_sym)
836
+ tracked = @state_mutex.synchronize { @instance_states.key?(name.to_sym) }
837
+ next if tracked
747
838
 
748
839
  claim_and_activate_instance(name: name, instance_cfg: instance_cfg)
749
840
  rescue StandardError => e
@@ -752,7 +843,8 @@ module Legion
752
843
  end
753
844
 
754
845
  def remove_unconfigured_instances(configured:)
755
- @instance_states.each do |name, state|
846
+ states = @state_mutex.synchronize { @instance_states.each_pair.to_a }
847
+ states.each do |name, state|
756
848
  next if configured.key?(name)
757
849
 
758
850
  drop_instance(name: name, state: state)
@@ -772,68 +864,48 @@ module Legion
772
864
  )
773
865
  readiness = check_readiness(instance_cfg: state[:instance_cfg])
774
866
  if readiness.ready?
775
- publisher.activate_instance_snapshot(
776
- instance_id: instance_id, physical_id: physical_id,
777
- publisher_token: state[:publisher_token],
778
- offerings: state[:offerings], sequence: 0, probe_token: probe_token
867
+ Legion::Extensions::Llm::Inventory::WeightReconciler.activate_tracked!(
868
+ settings: Legion::Settings,
869
+ instance_id: instance_id,
870
+ state_key: state[:name],
871
+ state: state,
872
+ states: @instance_states,
873
+ mutex: @state_mutex,
874
+ probe_token: probe_token,
875
+ activate: method(:activate_weight_snapshot),
876
+ activation_sequence: ->(tracked) { tracked.fetch(:sequence) }
779
877
  )
780
878
  else
879
+ tracked = @state_mutex.synchronize { @instance_states[state[:name]].equal?(state) }
880
+ return false unless tracked
881
+
781
882
  publisher.readiness_failed(
782
883
  instance_id: instance_id, physical_id: physical_id,
783
884
  probe_token: probe_token, reason: readiness.reason
784
885
  )
886
+ true
785
887
  end
786
888
  end
787
889
 
788
890
  def refresh_instance(state:)
789
- if publication_state(instance_key: state[:instance_key]) == :initializing
790
- retry_initial_activation(state: state)
791
- else
891
+ published = @state_mutex.synchronize { state[:published] }
892
+ if published
792
893
  replace_offerings_if_changed(state: state)
793
894
  run_cadence_probe(state: state)
895
+ else
896
+ retry_initial_activation(state: state)
794
897
  end
795
898
  end
796
899
 
797
- def publication_state(instance_key:)
798
- publisher.snapshot.publication_status(instance_key: instance_key).state
799
- end
800
-
801
900
  # An instance that failed initial readiness stays :initializing —
802
901
  # readiness_succeeded and replace_instance_snapshot both refuse to
803
902
  # operate on an :initializing scope, so without this re-activation
804
903
  # path a transient outage at boot pins the instance for the process
805
904
  # lifetime. Re-probe each tick and activate once readiness passes.
806
905
  def retry_initial_activation(state:)
807
- state[:offerings] = discover_offerings_for_instance(
808
- instance_cfg: state[:instance_cfg], instance_key: state[:instance_key]
809
- )
810
- settle_initial_readiness(state: state)
811
- sync_display_health(state: state)
812
- end
813
-
814
- def replace_offerings_if_changed(state:)
815
- new_offerings = discover_offerings_for_instance(
816
- instance_cfg: state[:instance_cfg], instance_key: state[:instance_key]
817
- )
818
- return unless offerings_changed?(previous: state[:offerings], current: new_offerings)
819
-
820
- state[:sequence] += 1
821
- publisher.replace_instance_snapshot(
822
- instance_id: state[:instance_id], physical_id: state[:physical_id],
823
- publisher_token: state[:publisher_token],
824
- offerings: new_offerings, sequence: state[:sequence]
825
- )
826
- state[:offerings] = new_offerings
827
- sync_display_health(state: state)
828
- end
829
-
830
- def remove_all_instances
831
- return unless @instance_states
832
-
833
- @instance_states.each do |name, state|
834
- drop_instance(name: name, state: state)
835
- end
836
- @instance_states.clear
906
+ replace_offerings_if_changed(state: state)
907
+ settled = settle_initial_readiness(state: state)
908
+ sync_display_health(state: state) if settled
837
909
  end
838
910
  end
839
911
 
@@ -857,6 +929,7 @@ module Legion
857
929
  include OfferingComparison
858
930
  include DisplayHealth
859
931
  include InstanceComponents
932
+ include WeightPublication
860
933
  include InstanceLifecycle
861
934
 
862
935
  def runner_class = self.class
@@ -405,7 +405,12 @@ module Legion
405
405
  content = message.content
406
406
  payload = { role: message.role.to_s, content: format_content(content) }
407
407
  payload[:images] = encoded_attachments(content) if content.respond_to?(:attachments)
408
- payload[:tool_call_id] = message.tool_call_id if message.tool_result?
408
+ tool_result = if message.respond_to?(:tool_result?)
409
+ message.tool_result?
410
+ else
411
+ message.role.to_sym == :tool
412
+ end
413
+ payload[:tool_call_id] = message.tool_call_id if tool_result && message.respond_to?(:tool_call_id)
409
414
  payload
410
415
  end
411
416
  end
@@ -4,7 +4,7 @@ module Legion
4
4
  module Extensions
5
5
  module Llm
6
6
  module Ollama
7
- VERSION = '0.3.4'
7
+ VERSION = '0.3.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-ollama
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.4
4
+ version: 0.3.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: Ollama provider integration for the LegionIO LLM routing framework.
83
83
  email:
84
84
  - matthewdiverson@gmail.com