lex-llm-anthropic 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: 2a55d5c398f1718d6cb12d15d3c2aa5c5b5e40216ac1de25119ad5ab36bfc04e
4
- data.tar.gz: 29f41f8a7d528f3b25055368daa731ed87fb16c64680ae3c6bd7267e33227313
3
+ metadata.gz: 37a7bf28c47d62711e925d75c0717bf45c952d4b899a5c84794f7544a395ad21
4
+ data.tar.gz: caad53642cf6b4123b41a30a0941dae91c587b7c79e89f16c49d737fb76aae13
5
5
  SHA512:
6
- metadata.gz: 06a7a0c4e20303cc0fd969c18409e11a3daf88cc3c9b02907b3d3b5fff77cf53b3f04f24b4541d9c49eb879f336d0fdda10767840748c3dfddc405474eb1dc61
7
- data.tar.gz: ca5ba8689d3ee56ec091a6adcedfd98c9ce37697e8bab4c3e21a0dacd7784ca03071fee2387da41c9fb833e54a7217b60a4d3c9198484f192fa2121c146e6c3a
6
+ metadata.gz: 217cbbf2136c44b3a362c78a8d787c646ac6a93746a3bd967a279301023cc5050e39fef902c38e624c1f7080806785784c1df93432c469c96221dbfb059c66b9
7
+ data.tar.gz: d6677021c2251c507705c6ada10d5378f54d97e20ea7176b0b00099ca62269063d593fce722c3d051dae53294eca1a8797769b7bf4edb18fb519fea485ef8b5b
data/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.3.5] - 2026-08-19
4
+
5
+ ### Changed
6
+ - **Write-time SSOT lane weights** — Anthropic discovery now computes the validated four-axis weight pair for every offering and reconciles weight-only changes on the existing periodic discovery cadence. Initial and recovery activation rebuild from current settings, and publication, sequence, and cache mutation share the actor's existing serialization boundary.
7
+ - **Dependency floor** — Requires `lex-llm >= 0.7.6` for the shared weight schema, immutable record pair, dormant-weight tracker, and atomic reconciler. The existing `legion-settings` floor and lifecycle remain unchanged.
8
+
9
+ ### Fixed
10
+ - **Complete discovery change detection** — Ordinary discovery cadence now compares the full stable `OfferingDraft` contract, excluding only telemetry-only evidence timestamps. Provider-native identity, evidence authority and metadata, scalar limits, quota domains, offering metadata, publication source, and weights all trigger one atomic replacement when changed; catalog reordering remains stable without collapsing duplicate offerings.
11
+ - **Two-phase publication races** — Offering and weight validation now completes before allocating a callable, probe coordinator, publisher token, registry scope, or local state; malformed weight configuration cannot strand an untracked claim or activate a healthy zero-lane instance. Claimed instances are tracked as unpublished before readiness I/O; removal wins a late-readiness race, and publisher failures leave cached state retryable without sequence drift.
12
+ - **Callable-path system conformance** — The provider formatter now accepts folded canonical messages as well as the legacy message shape. An end-to-end canonical fixture proves a folded leading system message reaches Anthropic's rendered native `system` field.
13
+
3
14
  ## [0.3.4] - 2026-08-18
4
15
 
5
16
  ### 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
@@ -13,6 +13,7 @@ require 'legion/extensions/llm/inventory/records'
13
13
  require 'legion/extensions/llm/inventory/evidence'
14
14
  require 'legion/extensions/llm/inventory/probe_coordinator'
15
15
  require 'legion/extensions/llm/inventory/scoped_refresher'
16
+ require 'legion/extensions/llm/inventory/weight_reconciler'
16
17
  require 'legion/extensions/llm/routing/provider_outcome'
17
18
  require 'legion/extensions/llm/taxonomies'
18
19
  require 'legion/extensions/llm/capabilities'
@@ -51,6 +52,10 @@ module Legion
51
52
  # Per-instance capabilities advertised in the settings health display,
52
53
  # mirroring the legacy discover_instances output.
53
54
  INSTANCE_CAPABILITIES = %i[completion streaming vision tools].freeze
55
+ SCALAR_EVIDENCE_FIELDS = %i[
56
+ context_evidence max_output_evidence embedding_dimensions_evidence
57
+ model_revision_evidence tokenizer_evidence
58
+ ].freeze
54
59
 
55
60
  def runner_class = self.class
56
61
  def runner_function = 'manual'
@@ -115,10 +120,17 @@ module Legion
115
120
  end
116
121
  end
117
122
 
123
+ def dormant_weight_tracker
124
+ @dormant_weight_tracker ||= Legion::Extensions::Llm::Inventory::DormantWeightTracker.new
125
+ end
126
+
118
127
  # ── Initial discovery ─────────────────────────────────────────────
119
128
 
120
129
  def initial_discovery
121
- with_instance_states { @instance_states = {} }
130
+ with_instance_states do |states|
131
+ states.clear
132
+ dormant_weight_tracker.clear!
133
+ end
122
134
  configured_instances.each do |name, instance_cfg|
123
135
  claim_and_activate_instance(name:, instance_cfg:)
124
136
  rescue StandardError => e
@@ -136,6 +148,10 @@ module Legion
136
148
  provider_family: :anthropic, instance_id: instance_id, physical_id: physical_id
137
149
  )
138
150
 
151
+ # Validate the complete offering set before allocating any
152
+ # callable, probe coordinator, publisher token, or local state.
153
+ offerings = discover_offerings_for_instance(instance_cfg:, instance_key:)
154
+
139
155
  callable = AnthropicCallable.new(instance_cfg: instance_cfg, logger: log)
140
156
  probe_coordinator = Legion::Extensions::Llm::Inventory::ProbeCoordinator.new(
141
157
  instance_key: instance_key,
@@ -149,7 +165,23 @@ module Legion
149
165
  physical_id: physical_id
150
166
  )
151
167
 
152
- offerings = discover_offerings_for_instance(instance_cfg:, instance_key:)
168
+ state = {
169
+ name: name,
170
+ instance_key: instance_key,
171
+ physical_id: physical_id,
172
+ instance_cfg: instance_cfg,
173
+ callable: callable,
174
+ probe_coordinator: probe_coordinator,
175
+ publisher_token: publisher_token,
176
+ sequence: 0,
177
+ offerings: offerings,
178
+ signature: offering_signature(offerings),
179
+ published: false
180
+ }
181
+ states = with_instance_states { |instance_states| instance_states }
182
+ Legion::Extensions::Llm::Inventory::WeightReconciler.track_initializing!(
183
+ states: states, state_key: instance_id, state: state, mutex: state_mutex
184
+ )
153
185
 
154
186
  probe_token = publisher.readiness_probe_started(
155
187
  instance_id: instance_id,
@@ -160,15 +192,14 @@ module Legion
160
192
  readiness = check_readiness(instance_cfg:)
161
193
 
162
194
  if readiness.ready?
163
- publisher.activate_instance_snapshot(
164
- instance_id: instance_id,
165
- publisher_token: publisher_token,
166
- offerings: offerings,
167
- sequence: 0,
168
- probe_token: probe_token,
169
- physical_id: physical_id
195
+ activated = Legion::Extensions::Llm::Inventory::WeightReconciler.activate_tracked!(
196
+ settings: Legion::Settings, instance_id: instance_id, state_key: instance_id,
197
+ state: state, states: states, mutex: state_mutex, probe_token: probe_token,
198
+ activate: method(:activate_weight_snapshot),
199
+ activation_sequence: ->(tracked) { tracked.fetch(:sequence) },
200
+ stable_signature: method(:offering_signature)
170
201
  )
171
- write_instance_display(name: name, ready: true, reason: readiness.reason)
202
+ write_instance_display(name: name, ready: true, reason: readiness.reason) if activated
172
203
  else
173
204
  publisher.readiness_failed(
174
205
  instance_id: instance_id,
@@ -178,21 +209,6 @@ module Legion
178
209
  )
179
210
  write_instance_display(name: name, ready: false, reason: readiness.reason)
180
211
  end
181
-
182
- with_instance_states do
183
- @instance_states[instance_id] = {
184
- name: name,
185
- instance_key: instance_key,
186
- physical_id: physical_id,
187
- instance_cfg: instance_cfg,
188
- callable: callable,
189
- probe_coordinator: probe_coordinator,
190
- publisher_token: publisher_token,
191
- sequence: 0,
192
- offerings: offerings,
193
- signature: offering_signature(offerings)
194
- }
195
- end
196
212
  end
197
213
 
198
214
  # ── Tick refresh ──────────────────────────────────────────────────
@@ -200,6 +216,7 @@ module Legion
200
216
  def tick_refresh
201
217
  reconcile_instances
202
218
  refresh_tracked_instances
219
+ observe_dormant_weights
203
220
  end
204
221
 
205
222
  # Re-scan configured instances each tick so instances added or removed
@@ -258,6 +275,7 @@ module Legion
258
275
  instance_cfg: state[:instance_cfg],
259
276
  instance_key: state[:instance_key]
260
277
  )
278
+ cache_discovered_offerings(instance_id:, state:, offerings:)
261
279
  probe_token = publisher.readiness_probe_started(
262
280
  instance_id: instance_id,
263
281
  publisher_token: state[:publisher_token],
@@ -266,19 +284,15 @@ module Legion
266
284
  readiness = check_readiness(instance_cfg: state[:instance_cfg])
267
285
 
268
286
  if readiness.ready?
269
- publisher.activate_instance_snapshot(
270
- instance_id: instance_id,
271
- publisher_token: state[:publisher_token],
272
- offerings: offerings,
273
- sequence: 0,
274
- probe_token: probe_token,
275
- physical_id: state[:physical_id]
287
+ states = with_instance_states { |instance_states| instance_states }
288
+ activated = Legion::Extensions::Llm::Inventory::WeightReconciler.activate_tracked!(
289
+ settings: Legion::Settings, instance_id: instance_id, state_key: instance_id,
290
+ state: state, states: states, mutex: state_mutex, probe_token: probe_token,
291
+ activate: method(:activate_weight_snapshot),
292
+ activation_sequence: ->(tracked) { tracked.fetch(:sequence) },
293
+ stable_signature: method(:offering_signature)
276
294
  )
277
- with_instance_states do
278
- state[:offerings] = offerings
279
- state[:signature] = offering_signature(offerings)
280
- end
281
- write_instance_display(name: state[:name], ready: true, reason: readiness.reason)
295
+ write_instance_display(name: state[:name], ready: true, reason: readiness.reason) if activated
282
296
  else
283
297
  publisher.readiness_failed(
284
298
  instance_id: instance_id,
@@ -298,29 +312,85 @@ module Legion
298
312
  instance_cfg: state[:instance_cfg],
299
313
  instance_key: state[:instance_key]
300
314
  )
301
- new_signature = offering_signature(new_offerings)
302
- return if new_signature == state[:signature]
303
-
304
- sequence = with_instance_states do
305
- state[:sequence] += 1
306
- state[:sequence]
315
+ changed = Legion::Extensions::Llm::Inventory::WeightReconciler.commit_if_changed!(
316
+ settings: Legion::Settings, instance_id: instance_id, state: state,
317
+ discovered_offerings: new_offerings, mutex: state_mutex,
318
+ equivalent: lambda { |previous, current|
319
+ offering_signature(previous) == offering_signature(current)
320
+ },
321
+ replace: method(:replace_weight_snapshot),
322
+ stable_signature: method(:offering_signature)
323
+ )
324
+ published = with_instance_states do |states|
325
+ states[instance_id].equal?(state) && state[:published]
307
326
  end
327
+ write_instance_display(name: state[:name], ready: true, reason: 'offerings refreshed') \
328
+ if changed && published
329
+ changed
330
+ end
331
+
332
+ def cache_discovered_offerings(instance_id:, state:, offerings:)
333
+ Legion::Extensions::Llm::Inventory::WeightReconciler.commit_if_changed!(
334
+ settings: Legion::Settings, instance_id: instance_id, state: state,
335
+ discovered_offerings: offerings, mutex: state_mutex,
336
+ equivalent: lambda { |previous, current|
337
+ offering_signature(previous) == offering_signature(current)
338
+ },
339
+ replace: method(:replace_weight_snapshot),
340
+ stable_signature: method(:offering_signature)
341
+ )
342
+ end
343
+
344
+ def replace_weight_snapshot(instance_id:, state:, offerings:, sequence:)
308
345
  publisher.replace_instance_snapshot(
309
- instance_id: instance_id,
310
- publisher_token: state[:publisher_token],
311
- offerings: new_offerings,
312
- sequence: sequence,
313
- physical_id: state[:physical_id]
346
+ instance_id: instance_id, publisher_token: state.fetch(:publisher_token),
347
+ offerings: offerings, sequence: sequence, physical_id: state.fetch(:physical_id)
348
+ )
349
+ end
350
+
351
+ def activate_weight_snapshot(instance_id:, state:, offerings:, sequence:, probe_token:)
352
+ publisher.activate_instance_snapshot(
353
+ instance_id: instance_id, publisher_token: state.fetch(:publisher_token),
354
+ offerings: offerings, sequence: sequence, probe_token: probe_token,
355
+ physical_id: state.fetch(:physical_id)
314
356
  )
315
- with_instance_states do
316
- state[:offerings] = new_offerings
317
- state[:signature] = new_signature
318
- end
319
- write_instance_display(name: state[:name], ready: true, reason: 'offerings refreshed')
320
357
  end
321
358
 
322
359
  def offering_signature(offerings)
323
- offerings.map { |offering| [offering.model, offering.tier] }.sort.freeze
360
+ # A tally is an order-independent multiset: catalog reordering is
361
+ # stable while duplicate offerings remain visible to comparison.
362
+ offerings.map { |offering| stable_offering_state(offering) }.tally.freeze
363
+ end
364
+
365
+ def stable_offering_state(offering)
366
+ state = offering.to_h
367
+ state[:operation_evidence] = stable_evidence_map(offering.operation_evidence)
368
+ state[:capability_evidence] = stable_evidence_map(offering.capability_evidence)
369
+ SCALAR_EVIDENCE_FIELDS.each do |field|
370
+ state[field] = stable_evidence(offering.public_send(field))
371
+ end
372
+ state.freeze
373
+ end
374
+
375
+ def stable_evidence_map(evidence)
376
+ evidence.transform_values { |entry| stable_evidence(entry) }.freeze
377
+ end
378
+
379
+ def stable_evidence(evidence)
380
+ evidence.to_h.except(:observed_at).freeze
381
+ end
382
+
383
+ def observe_dormant_weights
384
+ states = with_instance_states { |instance_states| instance_states }
385
+ Legion::Extensions::Llm::Inventory::WeightReconciler.observe_dormant!(
386
+ settings: Legion::Settings, provider_family: :anthropic, states: states,
387
+ mutex: state_mutex, tracker: dormant_weight_tracker,
388
+ dormant_logger: lambda { |key|
389
+ log.info(
390
+ "[llm][anthropic] action=dormant_weight weight_key=#{key.inspect} no_lane_published=true"
391
+ )
392
+ }
393
+ )
324
394
  end
325
395
 
326
396
  # ── Readiness probing ─────────────────────────────────────────────
@@ -449,7 +519,7 @@ module Legion
449
519
  instance_key: instance_key
450
520
  )
451
521
  end
452
- rescue StandardError => e
522
+ rescue Faraday::Error, Legion::JSON::ParseError => e
453
523
  handle_exception(e, level: :warn, operation: 'anthropic.actor.discover_offerings')
454
524
  []
455
525
  end
@@ -466,6 +536,10 @@ module Legion
466
536
 
467
537
  def build_offering_draft(model_id:, model_data:, instance_cfg:, instance_key:)
468
538
  tier = instance_cfg[:tier] || :frontier
539
+ weight_inputs = Legion::Extensions::Llm::Inventory::WeightSchema.weight_inputs(
540
+ settings: Legion::Settings, instance_key: instance_key,
541
+ provider_native_key: model_id, model: model_id, tier: tier
542
+ )
469
543
 
470
544
  Legion::Extensions::Llm::Inventory::OfferingDraft.new(
471
545
  provider_native_key: model_id,
@@ -480,7 +554,9 @@ module Legion
480
554
  tokenizer_evidence: absent_value_evidence,
481
555
  quota_domains: {},
482
556
  metadata: { raw_model: model_id, instance_id: instance_key.instance_id }.freeze,
483
- publication_source: :provider_catalog
557
+ publication_source: :provider_catalog,
558
+ weight_inputs: weight_inputs,
559
+ base_weight: Legion::Extensions::Llm::Inventory::WeightSchema.base_weight(weight_inputs)
484
560
  )
485
561
  end
486
562
 
@@ -638,21 +714,33 @@ module Legion
638
714
  def remove_all_instances
639
715
  tracked = with_instance_states { @instance_states.dup }
640
716
  tracked.each_value { |state| retire_instance(state) }
641
- with_instance_states { @instance_states.clear }
717
+ with_instance_states do |states|
718
+ states.clear
719
+ dormant_weight_tracker.clear!
720
+ end
642
721
  end
643
722
 
644
723
  # Retire one instance: remove it from the registry, drop the local
645
724
  # state, clear its settings display, and close its callable.
646
725
  def retire_instance(state)
647
726
  instance_id = state[:instance_key].instance_id
648
- publisher.remove_instance(
649
- instance_id: instance_id,
650
- publisher_token: state[:publisher_token],
651
- physical_id: state[:physical_id]
652
- )
653
- with_instance_states { @instance_states.delete(instance_id) }
727
+ removed = state_mutex.synchronize do
728
+ @instance_states ||= {}
729
+ next false unless @instance_states[instance_id].equal?(state)
730
+
731
+ publisher.remove_instance(
732
+ instance_id: instance_id,
733
+ publisher_token: state[:publisher_token],
734
+ physical_id: state[:physical_id]
735
+ )
736
+ @instance_states.delete(instance_id)
737
+ true
738
+ end
739
+ return false unless removed
740
+
654
741
  clear_instance_display(name: state[:name])
655
742
  state[:callable]&.disconnect
743
+ true
656
744
  rescue StandardError => e
657
745
  handle_exception(e, level: :warn, operation: 'anthropic.actor.retire_instance',
658
746
  instance_id: instance_id)
@@ -144,9 +144,9 @@ module Legion
144
144
  def format_messages(messages, thinking:, cacheable_count: 0)
145
145
  messages.each_with_index.map do |message, index|
146
146
  cache = index < cacheable_count
147
- if message.tool_call?
147
+ if message_tool_call?(message)
148
148
  format_tool_call_message(message, thinking:, cache:)
149
- elsif message.tool_result?
149
+ elsif message_tool_result?(message)
150
150
  format_tool_result_message(message, cache:)
151
151
  else
152
152
  {
@@ -207,10 +207,18 @@ module Legion
207
207
  def with_thinking(blocks, message, enabled)
208
208
  return blocks unless enabled && message&.role == :assistant
209
209
 
210
- thinking_block = thinking_block(message.thinking)
210
+ thinking_block = thinking_block(message.respond_to?(:thinking) ? message.thinking : nil)
211
211
  thinking_block ? [thinking_block, *blocks] : blocks
212
212
  end
213
213
 
214
+ def message_tool_call?(message)
215
+ !message.tool_calls.nil? && !message.tool_calls.empty?
216
+ end
217
+
218
+ def message_tool_result?(message)
219
+ !message.tool_call_id.nil? && !message.tool_call_id.to_s.empty?
220
+ end
221
+
214
222
  def format_tool_call_message(message, thinking:, cache:)
215
223
  blocks = content_blocks(message.content, thinking:, message:, cache:)
216
224
  # tool_calls is an Array of ToolCall since the adapter stopped
@@ -4,7 +4,7 @@ module Legion
4
4
  module Extensions
5
5
  module Llm
6
6
  module Anthropic
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-anthropic
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: Anthropic provider integration for the LegionIO LLM routing framework.
83
83
  email:
84
84
  - matthewdiverson@gmail.com