lex-llm 0.7.6 → 0.8.0

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.
Files changed (162) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +87 -0
  3. data/RULES.md +97 -0
  4. data/lib/legion/extensions/llm/auto_registration.rb +4 -13
  5. data/lib/legion/extensions/llm/canonical/chunk.rb +106 -92
  6. data/lib/legion/extensions/llm/canonical/content_block.rb +66 -75
  7. data/lib/legion/extensions/llm/canonical/message.rb +52 -67
  8. data/lib/legion/extensions/llm/canonical/params.rb +59 -33
  9. data/lib/legion/extensions/llm/canonical/request.rb +67 -54
  10. data/lib/legion/extensions/llm/canonical/response.rb +61 -76
  11. data/lib/legion/extensions/llm/canonical/strict.rb +105 -0
  12. data/lib/legion/extensions/llm/canonical/thinking.rb +99 -51
  13. data/lib/legion/extensions/llm/canonical/tool_call.rb +42 -51
  14. data/lib/legion/extensions/llm/canonical/tool_definition.rb +56 -46
  15. data/lib/legion/extensions/llm/canonical/tool_schema.rb +15 -22
  16. data/lib/legion/extensions/llm/canonical/usage.rb +47 -40
  17. data/lib/legion/extensions/llm/canonical.rb +6 -5
  18. data/lib/legion/extensions/llm/configuration.rb +40 -10
  19. data/lib/legion/extensions/llm/connection.rb +8 -30
  20. data/lib/legion/extensions/llm/credential_sources.rb +32 -49
  21. data/lib/legion/extensions/llm/discovery/actor.rb +92 -0
  22. data/lib/legion/extensions/llm/discovery/pipeline.rb +604 -0
  23. data/lib/legion/extensions/llm/error.rb +0 -14
  24. data/lib/legion/extensions/llm/fleet/contract_error.rb +15 -0
  25. data/lib/legion/extensions/llm/fleet/envelope_validation.rb +7 -6
  26. data/lib/legion/extensions/llm/fleet/fleet_envelope.rb +66 -0
  27. data/lib/legion/extensions/llm/fleet/protocol.rb +18 -5
  28. data/lib/legion/extensions/llm/fleet/provider_responder.rb +58 -156
  29. data/lib/legion/extensions/llm/fleet/token_validator.rb +15 -21
  30. data/lib/legion/extensions/llm/fleet/worker_execution.rb +107 -154
  31. data/lib/legion/extensions/llm/inventory/errors.rb +0 -1
  32. data/lib/legion/extensions/llm/inventory/evidence.rb +1 -1
  33. data/lib/legion/extensions/llm/inventory/identity.rb +55 -39
  34. data/lib/legion/extensions/llm/inventory/probe_token.rb +9 -12
  35. data/lib/legion/extensions/llm/inventory/publisher.rb +15 -53
  36. data/lib/legion/extensions/llm/inventory/records.rb +36 -100
  37. data/lib/legion/extensions/llm/inventory/registry.rb +54 -48
  38. data/lib/legion/extensions/llm/inventory/snapshot.rb +7 -21
  39. data/lib/legion/extensions/llm/inventory/weight_reconciler.rb +13 -6
  40. data/lib/legion/extensions/llm/inventory/weight_schema.rb +47 -8
  41. data/lib/legion/extensions/llm/provider/open_ai_compatible.rb +172 -182
  42. data/lib/legion/extensions/llm/provider.rb +192 -315
  43. data/lib/legion/extensions/llm/provider_contract.rb +25 -8
  44. data/lib/legion/extensions/llm/provider_settings.rb +5 -26
  45. data/lib/legion/extensions/llm/responses/thinking_extractor.rb +8 -1
  46. data/lib/legion/extensions/llm/responses/tool_arguments.rb +34 -0
  47. data/lib/legion/extensions/llm/routing/provider_outcome.rb +25 -0
  48. data/lib/legion/extensions/llm/routing/records.rb +34 -17
  49. data/lib/legion/extensions/llm/stream_accumulator.rb +186 -270
  50. data/lib/legion/extensions/llm/streaming.rb +50 -35
  51. data/lib/legion/extensions/llm/taxonomies.rb +14 -26
  52. data/lib/legion/extensions/llm/transport/fleet_lane.rb +8 -10
  53. data/lib/legion/extensions/llm/transport/messages/fleet_error.rb +3 -2
  54. data/lib/legion/extensions/llm/transport/messages/fleet_request.rb +6 -8
  55. data/lib/legion/extensions/llm/transport/messages/fleet_response.rb +10 -9
  56. data/lib/legion/extensions/llm/utils.rb +23 -5
  57. data/lib/legion/extensions/llm/version.rb +1 -1
  58. data/lib/legion/extensions/llm.rb +8 -98
  59. data/spec/legion/extensions/llm/auto_registration_spec.rb +4 -9
  60. data/spec/legion/extensions/llm/canonical/chunk_spec.rb +66 -252
  61. data/spec/legion/extensions/llm/canonical/content_block_spec.rb +52 -197
  62. data/spec/legion/extensions/llm/canonical/message_spec.rb +89 -204
  63. data/spec/legion/extensions/llm/canonical/params_spec.rb +57 -136
  64. data/spec/legion/extensions/llm/canonical/request_spec.rb +81 -143
  65. data/spec/legion/extensions/llm/canonical/response_spec.rb +68 -204
  66. data/spec/legion/extensions/llm/canonical/thinking_spec.rb +68 -148
  67. data/spec/legion/extensions/llm/canonical/tool_call_spec.rb +59 -162
  68. data/spec/legion/extensions/llm/canonical/tool_definition_spec.rb +55 -191
  69. data/spec/legion/extensions/llm/canonical/tool_schema_spec.rb +26 -67
  70. data/spec/legion/extensions/llm/canonical/usage_spec.rb +46 -155
  71. data/spec/legion/extensions/llm/configuration_spec.rb +31 -5
  72. data/spec/legion/extensions/llm/conformance/canonical_type_examples.rb +106 -0
  73. data/spec/legion/extensions/llm/conformance/conformance.rb +10 -2
  74. data/spec/legion/extensions/llm/conformance/provider_translator_examples.rb +1 -1
  75. data/spec/legion/extensions/llm/conformance/ssot_contract_conformance_spec.rb +130 -0
  76. data/spec/legion/extensions/llm/conformance/ssot_contract_examples.rb +507 -0
  77. data/spec/legion/extensions/llm/conformance/ssot_provider_examples.rb +11 -10
  78. data/spec/legion/extensions/llm/credential_sources_spec.rb +12 -13
  79. data/spec/legion/extensions/llm/error_spec.rb +2 -12
  80. data/spec/legion/extensions/llm/fleet/exact_offering_spec.rb +59 -45
  81. data/spec/legion/extensions/llm/fleet/provider_responder_spec.rb +173 -95
  82. data/spec/legion/extensions/llm/fleet/token_validator_spec.rb +7 -2
  83. data/spec/legion/extensions/llm/fleet/worker_execution_spec.rb +93 -74
  84. data/spec/legion/extensions/llm/fleet_messages_spec.rb +118 -123
  85. data/spec/legion/extensions/llm/inventory/boot_spec.rb +4 -4
  86. data/spec/legion/extensions/llm/inventory/identity_spec.rb +127 -110
  87. data/spec/legion/extensions/llm/inventory/probe_token_spec.rb +4 -4
  88. data/spec/legion/extensions/llm/inventory/publisher_spec.rb +8 -61
  89. data/spec/legion/extensions/llm/inventory/records_spec.rb +82 -47
  90. data/spec/legion/extensions/llm/inventory/registry_activation_spec.rb +49 -16
  91. data/spec/legion/extensions/llm/inventory/registry_replacement_spec.rb +5 -4
  92. data/spec/legion/extensions/llm/inventory/snapshot_spec.rb +11 -5
  93. data/spec/legion/extensions/llm/inventory/weight_reconciler_spec.rb +7 -3
  94. data/spec/legion/extensions/llm/inventory/weight_schema_spec.rb +60 -10
  95. data/spec/legion/extensions/llm/provider/open_ai_compatible_spec.rb +105 -68
  96. data/spec/legion/extensions/llm/provider/open_ai_compatible_tool_calls_array_spec.rb +7 -31
  97. data/spec/legion/extensions/llm/provider_contract_spec.rb +10 -15
  98. data/spec/legion/extensions/llm/provider_spec.rb +98 -78
  99. data/spec/legion/extensions/llm/routing/records_spec.rb +38 -5
  100. data/spec/legion/extensions/llm/stream_accumulator_spec.rb +174 -144
  101. data/spec/legion/extensions/llm/streaming_spec.rb +27 -0
  102. data/spec/legion/extensions/llm/taxonomies_spec.rb +43 -43
  103. data/spec/legion/extensions/llm/transport/fleet_lane_spec.rb +1 -1
  104. data/spec/legion/extensions/llm/utils_spec.rb +26 -7
  105. data/spec/legion/extensions/llm_base_contract_spec.rb +55 -90
  106. data/spec/legion/extensions/llm_extension_spec.rb +5 -5
  107. data/spec/support/fake_llm_provider.rb +45 -39
  108. data/spec/support/fake_ssot_harness.rb +7 -2
  109. metadata +11 -54
  110. data/lib/legion/extensions/llm/agent.rb +0 -366
  111. data/lib/legion/extensions/llm/aliases.json +0 -436
  112. data/lib/legion/extensions/llm/aliases.rb +0 -67
  113. data/lib/legion/extensions/llm/attachment.rb +0 -229
  114. data/lib/legion/extensions/llm/chat.rb +0 -354
  115. data/lib/legion/extensions/llm/chunk.rb +0 -10
  116. data/lib/legion/extensions/llm/content.rb +0 -81
  117. data/lib/legion/extensions/llm/context.rb +0 -33
  118. data/lib/legion/extensions/llm/embedding.rb +0 -33
  119. data/lib/legion/extensions/llm/image.rb +0 -109
  120. data/lib/legion/extensions/llm/inventory/capabilities.rb +0 -40
  121. data/lib/legion/extensions/llm/inventory/scoped_refresher.rb +0 -310
  122. data/lib/legion/extensions/llm/message.rb +0 -118
  123. data/lib/legion/extensions/llm/mime_type.rb +0 -75
  124. data/lib/legion/extensions/llm/model/info.rb +0 -286
  125. data/lib/legion/extensions/llm/model/modalities.rb +0 -26
  126. data/lib/legion/extensions/llm/model/pricing.rb +0 -52
  127. data/lib/legion/extensions/llm/model/pricing_category.rb +0 -50
  128. data/lib/legion/extensions/llm/model/pricing_tier.rb +0 -37
  129. data/lib/legion/extensions/llm/model.rb +0 -11
  130. data/lib/legion/extensions/llm/models.json +0 -57313
  131. data/lib/legion/extensions/llm/models.rb +0 -530
  132. data/lib/legion/extensions/llm/models_schema.json +0 -168
  133. data/lib/legion/extensions/llm/moderation.rb +0 -60
  134. data/lib/legion/extensions/llm/registry_event_builder.rb +0 -141
  135. data/lib/legion/extensions/llm/registry_publisher.rb +0 -107
  136. data/lib/legion/extensions/llm/responses/chat_response.rb +0 -43
  137. data/lib/legion/extensions/llm/responses/embedding_response.rb +0 -38
  138. data/lib/legion/extensions/llm/responses/stream_chunk.rb +0 -43
  139. data/lib/legion/extensions/llm/routing/lane_key.rb +0 -66
  140. data/lib/legion/extensions/llm/routing/model_offering.rb +0 -241
  141. data/lib/legion/extensions/llm/routing/offering_registry.rb +0 -101
  142. data/lib/legion/extensions/llm/routing/registry_event.rb +0 -167
  143. data/lib/legion/extensions/llm/thinking.rb +0 -53
  144. data/lib/legion/extensions/llm/tokens.rb +0 -51
  145. data/lib/legion/extensions/llm/tool_call.rb +0 -34
  146. data/lib/legion/extensions/llm/transcription.rb +0 -39
  147. data/lib/legion/extensions/llm/transport/messages/registry_event.rb +0 -44
  148. data/spec/legion/extensions/llm/agent_spec.rb +0 -179
  149. data/spec/legion/extensions/llm/attachment_spec.rb +0 -25
  150. data/spec/legion/extensions/llm/conformance/fixtures/ssot_identity_vectors.json +0 -84
  151. data/spec/legion/extensions/llm/context_spec.rb +0 -127
  152. data/spec/legion/extensions/llm/inventory/capabilities_spec.rb +0 -43
  153. data/spec/legion/extensions/llm/inventory/scoped_refresher_spec.rb +0 -340
  154. data/spec/legion/extensions/llm/message_spec.rb +0 -64
  155. data/spec/legion/extensions/llm/model/info_spec.rb +0 -222
  156. data/spec/legion/extensions/llm/models_spec.rb +0 -104
  157. data/spec/legion/extensions/llm/registry_event_builder_spec.rb +0 -68
  158. data/spec/legion/extensions/llm/registry_publisher_spec.rb +0 -22
  159. data/spec/legion/extensions/llm/responses/response_objects_spec.rb +0 -75
  160. data/spec/legion/extensions/llm/routing/model_offering_spec.rb +0 -281
  161. data/spec/legion/extensions/llm/routing/offering_registry_spec.rb +0 -50
  162. data/spec/legion/extensions/llm/routing/registry_event_spec.rb +0 -120
@@ -21,7 +21,6 @@ RSpec.describe Legion::Extensions::Llm::Inventory::Registry do
21
21
  snapshot = described_class.snapshot
22
22
  expect(snapshot.generation).to eq(0)
23
23
  expect(snapshot.each_lane.to_a).to be_empty
24
- expect(snapshot.each_offering.to_a).to be_empty
25
24
  expect(snapshot.instance(instance_key: key)).to be_nil
26
25
  end
27
26
  end
@@ -63,33 +62,67 @@ RSpec.describe Legion::Extensions::Llm::Inventory::Registry do
63
62
  .to raise_error(errors::InvalidTransitionError)
64
63
  end
65
64
 
66
- it 'atomically publishes callable, offerings, lanes, and available on activation' do
65
+ it 'atomically publishes callable, lanes, and available on activation' do
67
66
  claim_and_activate(key: key, callable: callable, coordinator: coordinator)
68
67
  snapshot = described_class.snapshot
69
68
  record = snapshot.instance(instance_key: key)
70
69
  expect(record).not_to be_nil
71
70
  expect(record.availability.state).to eq(:available)
72
71
  expect(snapshot.lanes_for(instance_key: key).size).to eq(1)
73
- expect(snapshot.offerings_for(instance_key: key).size).to eq(1)
74
72
  expect(snapshot.publication_status(instance_key: key).state).to eq(:complete)
75
73
  end
76
74
 
77
- it 'copies the draft weight pair unchanged onto offering and lane records' do
78
- weights = { tier: 150, provider: 100, instance: 115, model_or_offering: 100 }
75
+ it 'stores the lane keyed by the 5 tuple and exposes it through the record' do
76
+ claim_and_activate(key: key, callable: callable, coordinator: coordinator)
77
+ snapshot = described_class.snapshot
78
+ lane = snapshot.lanes_for(instance_key: key).first
79
+ expect(lane.lane_id).to eq('local:vllm:h200:inference:gemma4')
80
+ expect(snapshot.lane(lane_id: lane.lane_id)).to be(lane)
81
+ expect(snapshot.instance(instance_key: key).lanes_by_id.keys).to eq([lane.lane_id])
82
+ end
83
+
84
+ it 'derives one lane per supported operation of each draft' do
79
85
  claim_and_activate(
80
- key: key, callable: callable, coordinator: coordinator,
81
- weight_inputs: weights, base_weight: 172_500_000
86
+ key: key, callable: callable, coordinator: coordinator, supported: %i[chat embed]
82
87
  )
83
88
  snapshot = described_class.snapshot
84
- offering = snapshot.offerings_for(instance_key: key).first
85
- lane = snapshot.lanes_for(instance_key: key).first
89
+ expect(snapshot.lanes_for(instance_key: key).map(&:lane_id).sort).to eq(
90
+ %w[local:vllm:h200:embedding:gemma4 local:vllm:h200:inference:gemma4]
91
+ )
92
+ end
93
+
94
+ it 'raises on a duplicate (operation, model) published under a conflicting tier (D1)' do
95
+ key2 = instance_key(instance: 'tier-collision')
96
+ coordinator2 = probe_coordinator(key2)
97
+ token = described_class.claim_instance(instance_key: key2, callable: callable, probe_request_handle: coordinator2)
98
+ probe = described_class.readiness_probe_started(instance_key: key2, publisher_token: token)
99
+ expect do
100
+ described_class.activate_instance_snapshot(
101
+ publisher_token: token, instance_key: key2,
102
+ offerings: [
103
+ *drafts(model: 'm1', native: 'm1-a', supported: %i[chat], tier: :local),
104
+ *drafts(model: 'm1', native: 'm1-b', supported: %i[chat], tier: :frontier)
105
+ ],
106
+ sequence: 0, probe_token: probe
107
+ )
108
+ end.to raise_error(inventory::Errors::ValidationError, /conflicting tier/)
109
+ end
86
110
 
87
- expect(offering.weight_inputs).to eq(weights)
88
- expect(offering.base_weight).to eq(172_500_000)
89
- expect(lane.weight_inputs).to eq(weights)
90
- expect(lane.base_weight).to eq(172_500_000)
91
- expect(offering.weight_inputs).to be_frozen
92
- expect(lane.weight_inputs).to be_frozen
111
+ it 'raises on a duplicate 5 tuple from two native keys of the same model' do
112
+ key3 = instance_key(instance: 'dup-tuple')
113
+ coordinator3 = probe_coordinator(key3)
114
+ token = described_class.claim_instance(instance_key: key3, callable: callable, probe_request_handle: coordinator3)
115
+ probe = described_class.readiness_probe_started(instance_key: key3, publisher_token: token)
116
+ expect do
117
+ described_class.activate_instance_snapshot(
118
+ publisher_token: token, instance_key: key3,
119
+ offerings: [
120
+ *drafts(model: 'm1', native: 'm1-a', supported: %i[chat], tier: :local),
121
+ *drafts(model: 'm1', native: 'm1-b', supported: %i[chat], tier: :local)
122
+ ],
123
+ sequence: 0, probe_token: probe
124
+ )
125
+ end.to raise_error(inventory::Errors::ValidationError, /duplicate lane_id/)
93
126
  end
94
127
 
95
128
  it 'accepts an explicit complete empty activation as complete, not initializing' do
@@ -101,7 +134,7 @@ RSpec.describe Legion::Extensions::Llm::Inventory::Registry do
101
134
  expect(result.applied).to be(true)
102
135
  snapshot = described_class.snapshot
103
136
  expect(snapshot.publication_status(instance_key: key).state).to eq(:complete)
104
- expect(snapshot.instance(instance_key: key).offerings_by_id).to eq({})
137
+ expect(snapshot.instance(instance_key: key).lanes_by_id).to eq({})
105
138
  end
106
139
 
107
140
  it 'consumes the probe token so it cannot be reused' do
@@ -23,8 +23,9 @@ RSpec.describe Legion::Extensions::Llm::Inventory::Registry do
23
23
  )
24
24
  expect(result.applied).to be(true)
25
25
  expect(result.reason).to eq(:snapshot_replaced)
26
- offerings = described_class.snapshot.offerings_for(instance_key: key)
27
- expect(offerings.map(&:provider_native_key)).to eq(['gemma5'])
26
+ lanes = described_class.snapshot.lanes_for(instance_key: key)
27
+ expect(lanes.map(&:model)).to eq(['gemma5'])
28
+ expect(lanes.map(&:lane_id)).to eq(%w[local:vllm:h200:inference:gemma5])
28
29
  end
29
30
 
30
31
  it 'preserves the exact availability fact across replacement' do
@@ -45,7 +46,7 @@ RSpec.describe Legion::Extensions::Llm::Inventory::Registry do
45
46
  it 'treats an empty array as an authoritative complete empty snapshot' do
46
47
  token
47
48
  described_class.replace_instance_snapshot(publisher_token: token, instance_key: key, offerings: [], sequence: 1)
48
- expect(described_class.snapshot.offerings_for(instance_key: key)).to be_empty
49
+ expect(described_class.snapshot.lanes_for(instance_key: key)).to be_empty
49
50
  expect(described_class.snapshot.instance(instance_key: key).availability.state).to eq(:available)
50
51
  end
51
52
 
@@ -56,7 +57,7 @@ RSpec.describe Legion::Extensions::Llm::Inventory::Registry do
56
57
  expect { described_class.replace_instance_snapshot(publisher_token: token, instance_key: key, offerings: duplicate, sequence: 1) }
57
58
  .to raise_error(errors::ValidationError)
58
59
  expect(described_class.snapshot.generation).to eq(generation)
59
- expect(described_class.snapshot.offerings_for(instance_key: key).size).to eq(1)
60
+ expect(described_class.snapshot.lanes_for(instance_key: key).size).to eq(1)
60
61
  end
61
62
 
62
63
  it 'returns a stale publisher result for a superseded token' do
@@ -26,8 +26,7 @@ RSpec.describe Legion::Extensions::Llm::Inventory::Snapshot do
26
26
  record = snapshot.instance(instance_key: key_a)
27
27
  expect(record).to be_frozen
28
28
  expect(snapshot.instance(instance_key: instance_key(instance: 'absent'))).to be_nil
29
- expect(snapshot.lane(lane_id: 'lane:v1:missing')).to be_nil
30
- expect(snapshot.offering(offering_id: 'off:v1:missing')).to be_nil
29
+ expect(snapshot.lane(lane_id: 'local:absent:absent:inference:missing')).to be_nil
31
30
  end
32
31
 
33
32
  it 'only lists activated instances but keeps initializing claims in publication status' do
@@ -41,17 +40,24 @@ RSpec.describe Legion::Extensions::Llm::Inventory::Snapshot do
41
40
  end
42
41
 
43
42
  describe 'ordering and iteration' do
44
- it 'returns frozen lanes_for/offerings_for arrays ordered by id' do
43
+ it 'returns a frozen lanes_for array ordered by 5-tuple id' do
45
44
  snapshot = activate_both
46
45
  lanes = snapshot.lanes_for(instance_key: key_a)
47
46
  expect(lanes).to be_frozen
48
47
  expect(lanes.map(&:lane_id)).to eq(lanes.map(&:lane_id).sort)
48
+ expect(lanes.map(&:lane_id)).to all(match(/\A[a-z_]+:[a-z_0-9]+:[^:]+:[a-z_]+:.+\z/))
49
49
  end
50
50
 
51
- it 'each_lane/each_offering yield in id order and return an Enumerator without a block' do
51
+ it 'exposes lanes only — no offering surface' do
52
+ snapshot = activate_both
53
+ expect(snapshot).not_to respond_to(:offering)
54
+ expect(snapshot).not_to respond_to(:offerings_for)
55
+ expect(snapshot).not_to respond_to(:each_offering)
56
+ end
57
+
58
+ it 'each_lane yields in id order and returns an Enumerator without a block' do
52
59
  snapshot = activate_both
53
60
  expect(snapshot.each_lane).to be_a(Enumerator)
54
- expect(snapshot.each_offering).to be_a(Enumerator)
55
61
  expect(snapshot.each_lane.map(&:lane_id)).to eq(snapshot.each_lane.map(&:lane_id).sort)
56
62
  end
57
63
  end
@@ -244,13 +244,17 @@ RSpec.describe 'Inventory::WeightReconciler' do
244
244
  end
245
245
 
246
246
  it 'uses the exact provider, instance, model, instance-model, and offering dormant keys' do
247
- offering_id = inventory::Identity.offering_id(instance_key: key, provider_native_key: 'gemma4')
247
+ # The offering-scope key is the lane's 5 tuple — operator-readable.
248
+ offering_lane_id = inventory::Identity.compose_lane_id(
249
+ tier: :local, provider_family: 'vllm', instance_id: 'helios', type: :inference, model: 'gemma4'
250
+ )
251
+ expect(offering_lane_id).to eq('local:vllm:helios:inference:gemma4')
248
252
  settings = settings_for(
249
253
  provider: {
250
254
  weight: false,
251
255
  models: { gemma4: { weight: 101 } },
252
256
  instances: { helios: { weight: 0, models: { gemma4: { weight: 102 } } } },
253
- offerings: { offering_id => { weight: 103 } }
257
+ offerings: { offering_lane_id => { weight: 103 } }
254
258
  },
255
259
  tier: 777
256
260
  )
@@ -266,7 +270,7 @@ RSpec.describe 'Inventory::WeightReconciler' do
266
270
  [:vllm, :instance, 'helios'],
267
271
  [:vllm, :model, 'gemma4'],
268
272
  [:vllm, :instance, 'helios', :model, 'gemma4'],
269
- [:vllm, :offering, offering_id]
273
+ [:vllm, :offering, offering_lane_id]
270
274
  )
271
275
  expect(logged.flatten).not_to include(:tier)
272
276
 
@@ -12,25 +12,38 @@ RSpec.describe 'Inventory::WeightSchema' do
12
12
  inventory::Identity::InstanceKey.new(provider_family: :vllm, instance_id: 'helios')
13
13
  end
14
14
 
15
+ def operation_evidence(supported: %i[chat], **)
16
+ Legion::Extensions::Llm::Taxonomies::OPERATIONS.to_h do |op|
17
+ status, source = supported.include?(op) ? %i[supported provider_implementation] : %i[unknown absent]
18
+ [op, Legion::Extensions::Llm::Inventory::OperationEvidence.new(operation: op, status: status, source: source)]
19
+ end
20
+ end
21
+
15
22
  def settings_for(provider: {})
16
23
  { extensions: { llm: { vllm: provider } }, llm: { routing: { tier_weights: { direct: 100 } } } }
17
24
  end
18
25
 
19
- def inputs(settings, native: 'deployment-x', model: 'model-y')
26
+ # The offering-scope settings key is the lane's 5 tuple (operator-readable).
27
+ def lane_id(tier: :direct, model: 'model-y', type: :inference)
28
+ Legion::Extensions::Llm::Inventory::Identity.compose_lane_id(
29
+ tier: tier, provider_family: 'vllm', instance_id: 'helios', type: type, model: model
30
+ )
31
+ end
32
+
33
+ def inputs(settings, operation: :chat, model: 'model-y')
20
34
  schema.weight_inputs(
21
- settings: settings, instance_key: instance_key,
22
- provider_native_key: native, model: model, tier: :direct
35
+ settings: settings, instance_key: instance_key, model: model, tier: :direct,
36
+ operation_evidence: operation_evidence(supported: [operation])
23
37
  )
24
38
  end
25
39
 
26
40
  it 'multiplies independent scopes without double-counting and lets offering override model' do
27
- offering_id = inventory::Identity.offering_id(instance_key: instance_key, provider_native_key: 'deployment-x')
28
41
  settings = settings_for(
29
42
  provider: {
30
43
  weight: 100,
31
44
  models: { 'model-y' => { weight: 200 } },
32
45
  instances: { helios: { weight: 115 } },
33
- offerings: { offering_id => { weight: 300 } }
46
+ offerings: { lane_id => { weight: 300 } }
34
47
  }
35
48
  )
36
49
 
@@ -60,16 +73,53 @@ RSpec.describe 'Inventory::WeightSchema' do
60
73
  expect(schema.base_weight(identity)).to eq(100_000_000)
61
74
  end
62
75
 
63
- it 'derives offering identity from the provider-native key rather than the model' do
64
- deployment_id = inventory::Identity.offering_id(
65
- instance_key: instance_key, provider_native_key: 'deployment-x'
76
+ it 'treats the offering scope as the lane 5 tuple: a different model key does not apply' do
77
+ other_model_key = lane_id(model: 'other-model')
78
+ settings = settings_for(
79
+ provider: { offerings: { other_model_key => { weight: 999 }, lane_id => { weight: 321 } } }
66
80
  )
67
- model_id = inventory::Identity.offering_id(instance_key: instance_key, provider_native_key: 'model-y')
68
- settings = settings_for(provider: { offerings: { deployment_id => { weight: 321 }, model_id => { weight: 999 } } })
69
81
 
70
82
  expect(inputs(settings)[:model_or_offering]).to eq(321)
71
83
  end
72
84
 
85
+ it 'derives the offering scope type from the operation: an embed key does not apply to a chat lane' do
86
+ embed_key = lane_id(type: :embedding)
87
+ settings = settings_for(provider: { offerings: { embed_key => { weight: 999 } } })
88
+
89
+ expect(inputs(settings, operation: :embed)[:model_or_offering]).to eq(999)
90
+ expect(inputs(settings)[:model_or_offering]).to eq(100) # chat draft: no inference key configured
91
+ end
92
+
93
+ it 'falls through to the model scope when no offering-scope key is configured' do
94
+ settings = settings_for(provider: { models: { 'model-y' => { weight: 200 } } })
95
+ expect(inputs(settings)[:model_or_offering]).to eq(200)
96
+ end
97
+
98
+ it 'raises when a multi-type draft has differing configured weights across its lanes' do
99
+ inference_key = lane_id(type: :inference)
100
+ embedding_key = lane_id(type: :embedding)
101
+ settings = settings_for(
102
+ provider: { offerings: { inference_key => { weight: 100 }, embedding_key => { weight: 200 } } }
103
+ )
104
+ expect do
105
+ schema.weight_inputs(
106
+ settings: settings, instance_key: instance_key, model: 'model-y', tier: :direct,
107
+ operation_evidence: operation_evidence(supported: %i[chat embed])
108
+ )
109
+ end.to raise_error(ArgumentError, /ambiguous/)
110
+
111
+ # Equal weights across the draft's lanes resolve to that weight.
112
+ settings = settings_for(
113
+ provider: { offerings: { inference_key => { weight: 100 }, embedding_key => { weight: 100 } } }
114
+ )
115
+ expect(
116
+ schema.weight_inputs(
117
+ settings: settings, instance_key: instance_key, model: 'model-y', tier: :direct,
118
+ operation_evidence: operation_evidence(supported: %i[chat embed])
119
+ )[:model_or_offering]
120
+ ).to eq(100)
121
+ end
122
+
73
123
  it 'rejects every present malformed configuration scope instead of defaulting it' do
74
124
  cases = {
75
125
  'extensions.llm' => { extensions: { llm: false } },
@@ -3,6 +3,7 @@
3
3
  require 'spec_helper'
4
4
 
5
5
  RSpec.describe Legion::Extensions::Llm::Provider::OpenAICompatible do
6
+ let(:canonical) { Legion::Extensions::Llm::Canonical }
6
7
  let(:provider_class) do
7
8
  capability_object = Object.new
8
9
  def capability_object.chat?(model) = !embeddings?(model)
@@ -18,89 +19,135 @@ RSpec.describe Legion::Extensions::Llm::Provider::OpenAICompatible do
18
19
  end
19
20
  end
20
21
  let(:provider) { provider_class.new(Legion::Extensions::Llm.config) }
21
- let(:model) { Legion::Extensions::Llm::Model::Info.from_hash(id: 'model-a', provider: :openai_compatible) }
22
+ let(:model_id) { 'model-a' }
22
23
 
23
- it 'renders chat payloads for OpenAI-compatible servers' do
24
+ it 'renders chat payloads for OpenAI-compatible servers (canonical in, O4 temperature from Params)' do
24
25
  payload = chat_payload
25
26
 
26
- expect(payload.values_at(:model, :stream, :temperature)).to eq(['model-a', false, 0.2])
27
+ expect(payload.values_at(:model, :stream, :temperature)).to eq([model_id, false, 0.2])
27
28
  expect(payload[:messages]).to eq([{ role: 'user', content: 'hello' }])
28
29
  end
29
30
 
30
31
  it 'preserves assistant think tags in outbound OpenAI-compatible history' do
31
- message = Legion::Extensions::Llm::Message.new(
32
+ message = canonical::Message.build(
32
33
  role: :assistant,
33
- content: "internal\n</think>\n\nHello"
34
+ content: "internal\n</thinking>\n\nHello"
34
35
  )
35
36
 
36
37
  payload = provider.send(
37
38
  :render_payload,
38
39
  [message],
39
40
  tools: {},
40
- temperature: 0.2,
41
- model: model,
41
+ model: model_id,
42
42
  stream: false,
43
43
  schema: nil,
44
44
  thinking: nil,
45
- tool_prefs: nil
45
+ tool_prefs: nil,
46
+ params: canonical::Params.build(temperature: 0.2)
46
47
  )
47
48
 
48
- expect(payload[:messages]).to eq([{ role: 'assistant', content: "internal\n</think>\n\nHello" }])
49
+ expect(payload[:messages]).to eq([{ role: 'assistant', content: "internal\n</thinking>\n\nHello" }])
49
50
  end
50
51
 
51
- it 'parses chat completion responses with usage and tool calls' do
52
- message = provider.send(:parse_completion_response, fake_response(completion_body))
52
+ it 'renders content block arrays in the OpenAI wire shape' do
53
+ message = canonical::Message.build(
54
+ role: :user,
55
+ content: [canonical::ContentBlock.text('look at this'),
56
+ canonical::ContentBlock.image(data: 'AAA=', media_type: 'image/png')]
57
+ )
53
58
 
54
- expect([message.content, message.input_tokens, message.output_tokens]).to eq(['hi', 3, 5])
55
- expect(message.tool_calls[:lookup].arguments).to eq('id' => 1)
59
+ payload = provider.send(
60
+ :render_payload,
61
+ [message],
62
+ tools: {},
63
+ model: model_id,
64
+ stream: false,
65
+ schema: nil,
66
+ thinking: nil,
67
+ tool_prefs: nil,
68
+ params: nil
69
+ )
70
+
71
+ # Text blocks render as plain strings; non-text blocks as OpenAI content parts.
72
+ expect(payload[:messages].first[:content]).to eq(
73
+ ['look at this', { type: 'image', data: 'AAA=', media_type: 'image/png', source_type: :base64 }]
74
+ )
75
+ end
76
+
77
+ it 'parses chat completion responses to a Canonical::Response with usage and tool calls (08 R2)' do
78
+ response = provider.send(:parse_completion_response, fake_response(completion_body))
79
+
80
+ expect(response).to be_a(canonical::Response)
81
+ expect([response.text, response.usage.input_tokens, response.usage.output_tokens]).to eq(['hi', 3, 5])
82
+ expect(response.tool_calls.first).to be_a(canonical::ToolCall)
83
+ expect(response.tool_calls.first.arguments).to eq('id' => 1)
84
+ expect(response.stop_reason).to be_nil
85
+ end
86
+
87
+ it 'maps finish_reason through the StopReasonMapping vocabulary' do
88
+ response = provider.send(
89
+ :parse_completion_response,
90
+ fake_response({
91
+ 'model' => 'model-a',
92
+ 'choices' => [{ 'message' => { 'content' => 'hi' }, 'finish_reason' => 'tool_calls' }]
93
+ })
94
+ )
95
+
96
+ expect(response.stop_reason).to eq(:tool_use)
56
97
  end
57
98
 
58
99
  it 'strips malformed trailing think close tags from chat completion responses' do
59
- message = provider.send(
100
+ response = provider.send(
60
101
  :parse_completion_response,
61
- fake_response(completion_body(content: "hidden only\n</think>\n\nvisible"))
102
+ fake_response(completion_body(content: "hidden only\n</thinking>\n\nvisible"))
62
103
  )
63
104
 
64
- expect(message.content).to eq('visible')
65
- expect(message.thinking.text).to eq('hidden only')
105
+ expect(response.text).to eq('visible')
106
+ expect(response.thinking.content).to eq('hidden only')
66
107
  end
67
108
 
68
109
  it 'strips truncated think tags from chat completion responses' do
69
- message = provider.send(
110
+ response = provider.send(
70
111
  :parse_completion_response,
71
- fake_response(completion_body(content: "visible\n<think>hidden only"))
112
+ fake_response(completion_body(content: "visible\n<thinking>hidden only"))
72
113
  )
73
114
 
74
- expect(message.content).to eq('visible')
75
- expect(message.thinking.text).to eq('hidden only')
115
+ expect(response.text).to eq('visible')
116
+ expect(response.thinking.content).to eq('hidden only')
76
117
  end
77
118
 
78
119
  it 'does not leak streamed think-tag content split across chunks' do
79
120
  accumulator = Legion::Extensions::Llm::StreamAccumulator.new
80
- filtered = think_tag_stream.filter_map do |content|
81
- chunk = provider.send(:build_chunk, stream_delta(content: content))
82
- accumulator.add(chunk)
83
- accumulator.filtered_chunk(chunk)
121
+ emitted = []
122
+
123
+ think_tag_stream.each do |content|
124
+ emitted.concat(accumulator.add(provider.send(:build_chunk, stream_delta(content: content))))
84
125
  end
85
126
 
86
- message = accumulator.to_message(nil)
127
+ message = accumulator.to_response(model: model_id)
87
128
 
88
- expect(filtered.filter_map(&:content)).to eq(['visible'])
89
- expect(filtered.filter_map { |chunk| chunk.thinking&.text }.join).to eq('internal')
90
- expect(message.content).to eq('visible')
91
- expect(message.thinking.text).to eq('internal')
129
+ expect(emitted.select(&:text_delta?).map(&:delta)).to eq(['visible'])
130
+ expect(emitted.select(&:thinking_delta?).map(&:delta).join).to eq('internal')
131
+ expect(message.text).to eq('visible')
132
+ expect(message.thinking.content).to eq('internal')
92
133
  end
93
134
 
94
- it 'ignores incomplete OpenAI-compatible tool call deltas without a function name' do
95
- chunk = nil
135
+ it 'builds canonical chunks from streamed data (O5 — canonical out)' do
136
+ chunk = provider.send(:build_chunk, stream_delta(content: 'hello'))
137
+ expect(chunk).to be_a(canonical::Chunk)
138
+ expect(chunk.type).to eq(:text_delta)
139
+ expect(chunk.delta).to eq('hello')
96
140
 
97
- expect do
98
- chunk = provider.send(:build_chunk, stream_delta(tool_calls: [tool_call_delta_without_name]))
99
- end.not_to raise_error
141
+ usage_chunk = provider.send(:build_chunk, { 'choices' => [], 'usage' => { 'prompt_tokens' => 2 } })
142
+ expect(usage_chunk.type).to eq(:usage)
143
+ expect(usage_chunk.usage.input_tokens).to eq(2)
144
+ end
100
145
 
101
- expect(chunk.tool_calls[:'0'].id).to be_nil
102
- expect(chunk.tool_calls[:'0'].name).to be_nil
103
- expect(chunk.tool_calls[:'0'].arguments).to eq('{}')
146
+ it 'emits tool_call_delta chunks carrying the wire fragment (U1/U2 shapes)' do
147
+ chunk = provider.send(:build_chunk, stream_delta(tool_calls: [tool_call_delta_without_name]))
148
+ expect(chunk).to be_a(canonical::Chunk)
149
+ expect(chunk.type).to eq(:tool_call_delta)
150
+ expect(chunk.tool_call).to eq(id: nil, name: nil, arguments: '{}', index: 0)
104
151
  end
105
152
 
106
153
  it 'accumulates streamed OpenAI-compatible tool call argument fragments' do
@@ -110,52 +157,51 @@ RSpec.describe Legion::Extensions::Llm::Provider::OpenAICompatible do
110
157
  accumulator.add(provider.send(:build_chunk, stream_delta(tool_calls: [delta])))
111
158
  end
112
159
 
113
- message = accumulator.to_message(nil)
114
- expect(message.tool_calls['call-1'].arguments).to eq('city' => 'Chicago')
160
+ message = accumulator.to_response(model: model_id)
161
+ expect(message.tool_calls.first.name).to eq('lookup')
162
+ expect(message.tool_calls.first.arguments).to eq('city' => 'Chicago')
115
163
  end
116
164
 
117
165
  it 'renders embedding payloads with model ids' do
118
- payload = provider.send(:render_embedding_payload, 'hello', model: model, dimensions: 768)
166
+ payload = provider.send(:render_embedding_payload, 'hello', model: model_id, dimensions: 768)
119
167
 
120
- expect(payload).to eq(model: 'model-a', input: 'hello', dimensions: 768)
168
+ expect(payload).to eq(model: model_id, input: 'hello', dimensions: 768)
121
169
  end
122
170
 
123
- it 'parses embedding responses for single and batch inputs' do
171
+ it 'parses embedding responses to the documented artifact Hash (05 §3)' do
124
172
  single = provider.send(:parse_embedding_response, fake_response(embedding_body), model: 'embed', text: 'a')
125
173
  batch = provider.send(:parse_embedding_response, fake_response(embedding_body), model: 'embed', text: %w[a b])
126
174
 
127
- expect([single.vectors, batch.vectors]).to eq([[0.1], [[0.1], [0.2]]])
175
+ expect(single[:embedding]).to eq([0.1])
176
+ expect(batch[:embedding]).to eq([[0.1], [0.2]])
177
+ expect(single[:usage]).to be_a(canonical::Usage)
128
178
  end
129
179
 
130
- it 'maps OpenAI-compatible model listings to explicit capabilities and modalities' do
131
- models = provider.send(:parse_list_models_response, fake_response(models_body), :compatible,
132
- provider_class.capabilities)
180
+ it 'parses moderation responses to the documented artifact Hash (05 §3)' do
181
+ body = { 'model' => 'mod-1', 'results' => [{ 'flagged' => true, 'categories' => { 'hate' => true } }] }
182
+ result = provider.send(:parse_moderation_response, fake_response(body), model: 'mod-1')
133
183
 
134
- expect(models.map(&:capabilities)).to eq([%i[streaming function_calling tools], %i[embeddings embedding]])
135
- expect(models.map { |model| model.modalities.to_h }).to eq([
136
- { input: %w[text image], output: %w[text] },
137
- { input: %w[text], output: %w[embeddings] }
138
- ])
184
+ expect(result).to eq(model: 'mod-1', result: { flagged: true, categories: { 'hate' => true } })
139
185
  end
140
186
 
141
187
  def chat_payload
142
- message = Legion::Extensions::Llm::Message.new(role: :user, content: 'hello')
188
+ message = canonical::Message.build(role: :user, content: 'hello')
143
189
  provider.send(
144
190
  :render_payload,
145
191
  [message],
146
192
  tools: {},
147
- temperature: 0.2,
148
- model: model,
193
+ model: model_id,
149
194
  stream: false,
150
195
  schema: nil,
151
196
  thinking: nil,
152
- tool_prefs: nil
197
+ tool_prefs: nil,
198
+ params: canonical::Params.build(temperature: 0.2)
153
199
  )
154
200
  end
155
201
 
156
202
  def completion_body(content: 'hi')
157
203
  {
158
- 'model' => 'model-a',
204
+ 'model' => model_id,
159
205
  'choices' => [{ 'message' => { 'content' => content, 'tool_calls' => [tool_call] } }],
160
206
  'usage' => { 'prompt_tokens' => 3, 'completion_tokens' => 5 }
161
207
  }
@@ -177,26 +223,17 @@ RSpec.describe Legion::Extensions::Llm::Provider::OpenAICompatible do
177
223
  end
178
224
 
179
225
  def think_tag_stream
180
- ['<think>', 'internal', '</think>visible']
226
+ ['<thinking>', 'internal', '</thinking>visible']
181
227
  end
182
228
 
183
229
  def stream_delta(content: nil, tool_calls: nil)
184
- { 'model' => 'model-a', 'choices' => [{ 'delta' => { 'content' => content, 'tool_calls' => tool_calls }.compact }] }
230
+ { 'model' => model_id, 'choices' => [{ 'delta' => { 'content' => content, 'tool_calls' => tool_calls }.compact }] }
185
231
  end
186
232
 
187
233
  def embedding_body
188
234
  { 'data' => [{ 'embedding' => [0.1] }, { 'embedding' => [0.2] }], 'usage' => { 'prompt_tokens' => 2 } }
189
235
  end
190
236
 
191
- def models_body
192
- {
193
- 'data' => [
194
- { 'id' => 'chat-model', 'created' => 1 },
195
- { 'id' => 'embed-model', 'created' => 2 }
196
- ]
197
- }
198
- end
199
-
200
237
  def fake_response(body)
201
238
  Struct.new(:body).new(body)
202
239
  end
@@ -2,27 +2,10 @@
2
2
 
3
3
  require 'spec_helper'
4
4
  require 'legion/extensions/llm/provider/open_ai_compatible'
5
- require 'legion/extensions/llm/message'
6
- require 'legion/extensions/llm/tool_call'
7
5
 
8
- # Failing-test for the legionio-e2e claude/openai legionio_tool_injection
9
- # regression. After the LexLLMAdapter#normalize_message_tool_calls fix
10
- # (legion-llm 4317d56), the adapter now emits Array<ToolCall> on the
11
- # assistant message — which is the historical canonical shape per
12
- # canonical/message.rb:75 ("Array is canonical; Hash is legacy lex-llm
13
- # format (name → ToolCall)"). The OpenAI-compatible HTTP payload renderer
14
- # missed that update and still calls `.values` on tool_calls, so live
15
- # requests now blow up with:
16
- #
17
- # undefined method 'values' for an instance of Array
18
- #
19
- # (captured at
20
- # legionio-e2e/results/claude/openai_legionio_tool_injection_returns_response_containing_legionio_tool_references_response.json)
21
- #
22
- # The bedrock provider (format_invoke_model_assistant) and the
23
- # lex-llm-openai canonical translator (translator.rb:290) already handle
24
- # both shapes via `tool_calls.is_a?(Hash) ? .values : Array(...)`. This
25
- # spec pins the same shape-tolerance for OpenAICompatible.
6
+ # Regression pin for the legionio-e2e claude/openai legionio_tool_injection
7
+ # defect: the renderer must handle Array<Canonical::ToolCall> (the canonical
8
+ # shape). 0.8.0: the legacy Hash shape is deleted (O03a/L4 — Array only).
26
9
  RSpec.describe Legion::Extensions::Llm::Provider::OpenAICompatible do
27
10
  let(:host_class) do
28
11
  Class.new do
@@ -37,32 +20,25 @@ RSpec.describe Legion::Extensions::Llm::Provider::OpenAICompatible do
37
20
  let(:host) { host_class.new }
38
21
 
39
22
  let(:tool_call) do
40
- Legion::Extensions::Llm::ToolCall.new(
41
- id: 'call_001',
23
+ Legion::Extensions::Llm::Canonical::ToolCall.build(
42
24
  name: 'legion_list_all_tools',
25
+ id: 'call_001',
43
26
  arguments: { 'filter' => 'all' }
44
27
  )
45
28
  end
46
29
 
47
- it 'renders an Array<ToolCall> (the post-canonical shape)' do
30
+ it 'renders an Array<Canonical::ToolCall>' do
48
31
  rendered = host.format_openai_tool_calls([tool_call])
49
32
  expect(rendered).to be_an(Array)
50
33
  expect(rendered.size).to eq(1)
51
34
  expect(rendered.first[:id]).to eq('call_001')
52
35
  expect(rendered.first[:type]).to eq('function')
53
36
  expect(rendered.first[:function][:name]).to eq('legion_list_all_tools')
54
- end
55
-
56
- it 'renders a Hash<id, ToolCall> (legacy lex-llm shape) without regression' do
57
- rendered = host.format_openai_tool_calls({ call_id: tool_call })
58
- expect(rendered).to be_an(Array)
59
- expect(rendered.size).to eq(1)
60
- expect(rendered.first[:function][:name]).to eq('legion_list_all_tools')
37
+ expect(Legion::JSON.parse(rendered.first[:function][:arguments])).to eq(filter: 'all')
61
38
  end
62
39
 
63
40
  it 'returns nil when tool_calls is nil or empty' do
64
41
  expect(host.format_openai_tool_calls(nil)).to be_nil
65
42
  expect(host.format_openai_tool_calls([])).to be_nil
66
- expect(host.format_openai_tool_calls({})).to be_nil
67
43
  end
68
44
  end