lex-llm 0.7.6 → 0.8.4

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 (168) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +139 -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 +107 -94
  6. data/lib/legion/extensions/llm/canonical/content_block.rb +65 -75
  7. data/lib/legion/extensions/llm/canonical/message.rb +53 -69
  8. data/lib/legion/extensions/llm/canonical/params.rb +58 -34
  9. data/lib/legion/extensions/llm/canonical/request.rb +68 -56
  10. data/lib/legion/extensions/llm/canonical/response.rb +62 -78
  11. data/lib/legion/extensions/llm/canonical/strict.rb +105 -0
  12. data/lib/legion/extensions/llm/canonical/thinking.rb +36 -84
  13. data/lib/legion/extensions/llm/canonical/thinking_config.rb +149 -0
  14. data/lib/legion/extensions/llm/canonical/tool_call.rb +43 -53
  15. data/lib/legion/extensions/llm/canonical/tool_definition.rb +56 -46
  16. data/lib/legion/extensions/llm/canonical/tool_schema.rb +15 -22
  17. data/lib/legion/extensions/llm/canonical/usage.rb +47 -40
  18. data/lib/legion/extensions/llm/canonical.rb +7 -5
  19. data/lib/legion/extensions/llm/configuration.rb +40 -10
  20. data/lib/legion/extensions/llm/connection.rb +8 -30
  21. data/lib/legion/extensions/llm/credential_sources.rb +32 -49
  22. data/lib/legion/extensions/llm/discovery/actor.rb +92 -0
  23. data/lib/legion/extensions/llm/discovery/pipeline.rb +604 -0
  24. data/lib/legion/extensions/llm/error.rb +0 -14
  25. data/lib/legion/extensions/llm/fleet/contract_error.rb +15 -0
  26. data/lib/legion/extensions/llm/fleet/envelope_validation.rb +7 -6
  27. data/lib/legion/extensions/llm/fleet/fleet_envelope.rb +66 -0
  28. data/lib/legion/extensions/llm/fleet/protocol.rb +18 -5
  29. data/lib/legion/extensions/llm/fleet/provider_responder.rb +58 -156
  30. data/lib/legion/extensions/llm/fleet/token_validator.rb +15 -21
  31. data/lib/legion/extensions/llm/fleet/worker_execution.rb +107 -154
  32. data/lib/legion/extensions/llm/inventory/errors.rb +0 -1
  33. data/lib/legion/extensions/llm/inventory/evidence.rb +1 -1
  34. data/lib/legion/extensions/llm/inventory/identity.rb +55 -39
  35. data/lib/legion/extensions/llm/inventory/probe_token.rb +9 -12
  36. data/lib/legion/extensions/llm/inventory/publisher.rb +15 -53
  37. data/lib/legion/extensions/llm/inventory/records.rb +36 -100
  38. data/lib/legion/extensions/llm/inventory/registry.rb +54 -48
  39. data/lib/legion/extensions/llm/inventory/snapshot.rb +7 -21
  40. data/lib/legion/extensions/llm/inventory/weight_reconciler.rb +13 -6
  41. data/lib/legion/extensions/llm/inventory/weight_schema.rb +47 -8
  42. data/lib/legion/extensions/llm/provider/open_ai_compatible.rb +178 -182
  43. data/lib/legion/extensions/llm/provider.rb +192 -315
  44. data/lib/legion/extensions/llm/provider_contract.rb +25 -8
  45. data/lib/legion/extensions/llm/provider_settings.rb +5 -26
  46. data/lib/legion/extensions/llm/responses/thinking_extractor.rb +8 -1
  47. data/lib/legion/extensions/llm/responses/tool_arguments.rb +48 -0
  48. data/lib/legion/extensions/llm/routing/provider_outcome.rb +25 -0
  49. data/lib/legion/extensions/llm/routing/records.rb +34 -17
  50. data/lib/legion/extensions/llm/stream_accumulator.rb +186 -270
  51. data/lib/legion/extensions/llm/streaming.rb +50 -35
  52. data/lib/legion/extensions/llm/taxonomies.rb +14 -26
  53. data/lib/legion/extensions/llm/transport/fleet_lane.rb +8 -10
  54. data/lib/legion/extensions/llm/transport/messages/fleet_error.rb +3 -2
  55. data/lib/legion/extensions/llm/transport/messages/fleet_request.rb +6 -8
  56. data/lib/legion/extensions/llm/transport/messages/fleet_response.rb +10 -9
  57. data/lib/legion/extensions/llm/utils.rb +23 -5
  58. data/lib/legion/extensions/llm/version.rb +1 -1
  59. data/lib/legion/extensions/llm.rb +8 -98
  60. data/spec/legion/extensions/llm/auto_registration_spec.rb +4 -9
  61. data/spec/legion/extensions/llm/canonical/chunk_spec.rb +66 -252
  62. data/spec/legion/extensions/llm/canonical/content_block_spec.rb +52 -197
  63. data/spec/legion/extensions/llm/canonical/message_spec.rb +89 -204
  64. data/spec/legion/extensions/llm/canonical/params_spec.rb +55 -136
  65. data/spec/legion/extensions/llm/canonical/request_spec.rb +81 -143
  66. data/spec/legion/extensions/llm/canonical/response_spec.rb +68 -204
  67. data/spec/legion/extensions/llm/canonical/thinking/config_spec.rb +222 -0
  68. data/spec/legion/extensions/llm/canonical/thinking_spec.rb +23 -171
  69. data/spec/legion/extensions/llm/canonical/tool_call_spec.rb +59 -162
  70. data/spec/legion/extensions/llm/canonical/tool_definition_spec.rb +55 -191
  71. data/spec/legion/extensions/llm/canonical/tool_schema_spec.rb +26 -67
  72. data/spec/legion/extensions/llm/canonical/usage_spec.rb +46 -155
  73. data/spec/legion/extensions/llm/configuration_spec.rb +31 -5
  74. data/spec/legion/extensions/llm/conformance/canonical_type_examples.rb +106 -0
  75. data/spec/legion/extensions/llm/conformance/client_translator_examples.rb +1 -2
  76. data/spec/legion/extensions/llm/conformance/conformance.rb +10 -2
  77. data/spec/legion/extensions/llm/conformance/fixtures/canonical_fleet_round_trip.json +1 -1
  78. data/spec/legion/extensions/llm/conformance/fixtures/canonical_thinking_request.json +2 -2
  79. data/spec/legion/extensions/llm/conformance/provider_translator_examples.rb +1 -1
  80. data/spec/legion/extensions/llm/conformance/ssot_contract_conformance_spec.rb +128 -0
  81. data/spec/legion/extensions/llm/conformance/ssot_contract_examples.rb +505 -0
  82. data/spec/legion/extensions/llm/conformance/ssot_provider_examples.rb +11 -10
  83. data/spec/legion/extensions/llm/credential_sources_spec.rb +12 -13
  84. data/spec/legion/extensions/llm/error_spec.rb +2 -12
  85. data/spec/legion/extensions/llm/fleet/exact_offering_spec.rb +59 -45
  86. data/spec/legion/extensions/llm/fleet/provider_responder_spec.rb +173 -95
  87. data/spec/legion/extensions/llm/fleet/token_validator_spec.rb +7 -2
  88. data/spec/legion/extensions/llm/fleet/worker_execution_spec.rb +93 -74
  89. data/spec/legion/extensions/llm/fleet_messages_spec.rb +119 -125
  90. data/spec/legion/extensions/llm/gemspec_spec.rb +1 -2
  91. data/spec/legion/extensions/llm/inventory/boot_spec.rb +4 -4
  92. data/spec/legion/extensions/llm/inventory/identity_spec.rb +127 -110
  93. data/spec/legion/extensions/llm/inventory/probe_token_spec.rb +4 -4
  94. data/spec/legion/extensions/llm/inventory/publisher_spec.rb +8 -61
  95. data/spec/legion/extensions/llm/inventory/records_spec.rb +82 -47
  96. data/spec/legion/extensions/llm/inventory/registry_activation_spec.rb +49 -16
  97. data/spec/legion/extensions/llm/inventory/registry_replacement_spec.rb +5 -4
  98. data/spec/legion/extensions/llm/inventory/snapshot_spec.rb +11 -5
  99. data/spec/legion/extensions/llm/inventory/weight_reconciler_spec.rb +7 -3
  100. data/spec/legion/extensions/llm/inventory/weight_schema_spec.rb +60 -10
  101. data/spec/legion/extensions/llm/provider/open_ai_compatible_spec.rb +128 -68
  102. data/spec/legion/extensions/llm/provider/open_ai_compatible_tool_calls_array_spec.rb +7 -31
  103. data/spec/legion/extensions/llm/provider_contract_spec.rb +10 -15
  104. data/spec/legion/extensions/llm/provider_spec.rb +97 -78
  105. data/spec/legion/extensions/llm/routing/records_spec.rb +38 -5
  106. data/spec/legion/extensions/llm/stream_accumulator_spec.rb +174 -144
  107. data/spec/legion/extensions/llm/streaming_spec.rb +27 -0
  108. data/spec/legion/extensions/llm/taxonomies_spec.rb +43 -43
  109. data/spec/legion/extensions/llm/transport/fleet_lane_spec.rb +1 -1
  110. data/spec/legion/extensions/llm/utils_spec.rb +26 -7
  111. data/spec/legion/extensions/llm_base_contract_spec.rb +55 -90
  112. data/spec/legion/extensions/llm_extension_spec.rb +5 -5
  113. data/spec/support/fake_llm_provider.rb +45 -39
  114. data/spec/support/fake_ssot_harness.rb +7 -2
  115. metadata +13 -54
  116. data/lib/legion/extensions/llm/agent.rb +0 -366
  117. data/lib/legion/extensions/llm/aliases.json +0 -436
  118. data/lib/legion/extensions/llm/aliases.rb +0 -67
  119. data/lib/legion/extensions/llm/attachment.rb +0 -229
  120. data/lib/legion/extensions/llm/chat.rb +0 -354
  121. data/lib/legion/extensions/llm/chunk.rb +0 -10
  122. data/lib/legion/extensions/llm/content.rb +0 -81
  123. data/lib/legion/extensions/llm/context.rb +0 -33
  124. data/lib/legion/extensions/llm/embedding.rb +0 -33
  125. data/lib/legion/extensions/llm/image.rb +0 -109
  126. data/lib/legion/extensions/llm/inventory/capabilities.rb +0 -40
  127. data/lib/legion/extensions/llm/inventory/scoped_refresher.rb +0 -310
  128. data/lib/legion/extensions/llm/message.rb +0 -118
  129. data/lib/legion/extensions/llm/mime_type.rb +0 -75
  130. data/lib/legion/extensions/llm/model/info.rb +0 -286
  131. data/lib/legion/extensions/llm/model/modalities.rb +0 -26
  132. data/lib/legion/extensions/llm/model/pricing.rb +0 -52
  133. data/lib/legion/extensions/llm/model/pricing_category.rb +0 -50
  134. data/lib/legion/extensions/llm/model/pricing_tier.rb +0 -37
  135. data/lib/legion/extensions/llm/model.rb +0 -11
  136. data/lib/legion/extensions/llm/models.json +0 -57313
  137. data/lib/legion/extensions/llm/models.rb +0 -530
  138. data/lib/legion/extensions/llm/models_schema.json +0 -168
  139. data/lib/legion/extensions/llm/moderation.rb +0 -60
  140. data/lib/legion/extensions/llm/registry_event_builder.rb +0 -141
  141. data/lib/legion/extensions/llm/registry_publisher.rb +0 -107
  142. data/lib/legion/extensions/llm/responses/chat_response.rb +0 -43
  143. data/lib/legion/extensions/llm/responses/embedding_response.rb +0 -38
  144. data/lib/legion/extensions/llm/responses/stream_chunk.rb +0 -43
  145. data/lib/legion/extensions/llm/routing/lane_key.rb +0 -66
  146. data/lib/legion/extensions/llm/routing/model_offering.rb +0 -241
  147. data/lib/legion/extensions/llm/routing/offering_registry.rb +0 -101
  148. data/lib/legion/extensions/llm/routing/registry_event.rb +0 -167
  149. data/lib/legion/extensions/llm/thinking.rb +0 -53
  150. data/lib/legion/extensions/llm/tokens.rb +0 -51
  151. data/lib/legion/extensions/llm/tool_call.rb +0 -34
  152. data/lib/legion/extensions/llm/transcription.rb +0 -39
  153. data/lib/legion/extensions/llm/transport/messages/registry_event.rb +0 -44
  154. data/spec/legion/extensions/llm/agent_spec.rb +0 -179
  155. data/spec/legion/extensions/llm/attachment_spec.rb +0 -25
  156. data/spec/legion/extensions/llm/conformance/fixtures/ssot_identity_vectors.json +0 -84
  157. data/spec/legion/extensions/llm/context_spec.rb +0 -127
  158. data/spec/legion/extensions/llm/inventory/capabilities_spec.rb +0 -43
  159. data/spec/legion/extensions/llm/inventory/scoped_refresher_spec.rb +0 -340
  160. data/spec/legion/extensions/llm/message_spec.rb +0 -64
  161. data/spec/legion/extensions/llm/model/info_spec.rb +0 -222
  162. data/spec/legion/extensions/llm/models_spec.rb +0 -104
  163. data/spec/legion/extensions/llm/registry_event_builder_spec.rb +0 -68
  164. data/spec/legion/extensions/llm/registry_publisher_spec.rb +0 -22
  165. data/spec/legion/extensions/llm/responses/response_objects_spec.rb +0 -75
  166. data/spec/legion/extensions/llm/routing/model_offering_spec.rb +0 -281
  167. data/spec/legion/extensions/llm/routing/offering_registry_spec.rb +0 -50
  168. 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,158 @@ 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
+ describe 'reasoning_effort transposition (canonical Thinking::Config -> OpenAI wire)' do
53
+ def payload_for(thinking)
54
+ provider.send(:render_payload, [], tools: {}, model: model_id, stream: false,
55
+ schema: nil, thinking: thinking, tool_prefs: nil, params: nil)
56
+ end
57
+
58
+ it 'passes an explicit effort through' do
59
+ expect(payload_for(canonical::Thinking::Config.build(effort: 'high'))[:reasoning_effort]).to eq('high')
60
+ end
61
+
62
+ it 'derives effort from a budget-only config so it is never dropped' do
63
+ expect(payload_for(canonical::Thinking::Config.build(budget: 8192))[:reasoning_effort]).to eq('medium')
64
+ end
65
+
66
+ it 'omits reasoning_effort when thinking is explicitly disabled' do
67
+ expect(payload_for(canonical::Thinking::Config.build(enabled: false))).not_to have_key(:reasoning_effort)
68
+ end
69
+
70
+ it 'omits reasoning_effort when there is no thinking' do
71
+ expect(payload_for(nil)).not_to have_key(:reasoning_effort)
72
+ end
73
+ end
74
+
75
+ it 'renders content block arrays in the OpenAI wire shape' do
76
+ message = canonical::Message.build(
77
+ role: :user,
78
+ content: [canonical::ContentBlock.text('look at this'),
79
+ canonical::ContentBlock.image(data: 'AAA=', media_type: 'image/png')]
80
+ )
81
+
82
+ payload = provider.send(
83
+ :render_payload,
84
+ [message],
85
+ tools: {},
86
+ model: model_id,
87
+ stream: false,
88
+ schema: nil,
89
+ thinking: nil,
90
+ tool_prefs: nil,
91
+ params: nil
92
+ )
93
+
94
+ # Text blocks render as plain strings; non-text blocks as OpenAI content parts.
95
+ expect(payload[:messages].first[:content]).to eq(
96
+ ['look at this', { type: 'image', data: 'AAA=', media_type: 'image/png', source_type: :base64 }]
97
+ )
98
+ end
99
+
100
+ it 'parses chat completion responses to a Canonical::Response with usage and tool calls (08 R2)' do
101
+ response = provider.send(:parse_completion_response, fake_response(completion_body))
102
+
103
+ expect(response).to be_a(canonical::Response)
104
+ expect([response.text, response.usage.input_tokens, response.usage.output_tokens]).to eq(['hi', 3, 5])
105
+ expect(response.tool_calls.first).to be_a(canonical::ToolCall)
106
+ expect(response.tool_calls.first.arguments).to eq('id' => 1)
107
+ expect(response.stop_reason).to be_nil
108
+ end
53
109
 
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)
110
+ it 'maps finish_reason through the StopReasonMapping vocabulary' do
111
+ response = provider.send(
112
+ :parse_completion_response,
113
+ fake_response({
114
+ 'model' => 'model-a',
115
+ 'choices' => [{ 'message' => { 'content' => 'hi' }, 'finish_reason' => 'tool_calls' }]
116
+ })
117
+ )
118
+
119
+ expect(response.stop_reason).to eq(:tool_use)
56
120
  end
57
121
 
58
122
  it 'strips malformed trailing think close tags from chat completion responses' do
59
- message = provider.send(
123
+ response = provider.send(
60
124
  :parse_completion_response,
61
- fake_response(completion_body(content: "hidden only\n</think>\n\nvisible"))
125
+ fake_response(completion_body(content: "hidden only\n</thinking>\n\nvisible"))
62
126
  )
63
127
 
64
- expect(message.content).to eq('visible')
65
- expect(message.thinking.text).to eq('hidden only')
128
+ expect(response.text).to eq('visible')
129
+ expect(response.thinking.content).to eq('hidden only')
66
130
  end
67
131
 
68
132
  it 'strips truncated think tags from chat completion responses' do
69
- message = provider.send(
133
+ response = provider.send(
70
134
  :parse_completion_response,
71
- fake_response(completion_body(content: "visible\n<think>hidden only"))
135
+ fake_response(completion_body(content: "visible\n<thinking>hidden only"))
72
136
  )
73
137
 
74
- expect(message.content).to eq('visible')
75
- expect(message.thinking.text).to eq('hidden only')
138
+ expect(response.text).to eq('visible')
139
+ expect(response.thinking.content).to eq('hidden only')
76
140
  end
77
141
 
78
142
  it 'does not leak streamed think-tag content split across chunks' do
79
143
  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)
144
+ emitted = []
145
+
146
+ think_tag_stream.each do |content|
147
+ emitted.concat(accumulator.add(provider.send(:build_chunk, stream_delta(content: content))))
84
148
  end
85
149
 
86
- message = accumulator.to_message(nil)
150
+ message = accumulator.to_response(model: model_id)
87
151
 
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')
152
+ expect(emitted.select(&:text_delta?).map(&:delta)).to eq(['visible'])
153
+ expect(emitted.select(&:thinking_delta?).map(&:delta).join).to eq('internal')
154
+ expect(message.text).to eq('visible')
155
+ expect(message.thinking.content).to eq('internal')
92
156
  end
93
157
 
94
- it 'ignores incomplete OpenAI-compatible tool call deltas without a function name' do
95
- chunk = nil
158
+ it 'builds canonical chunks from streamed data (O5 — canonical out)' do
159
+ chunk = provider.send(:build_chunk, stream_delta(content: 'hello'))
160
+ expect(chunk).to be_a(canonical::Chunk)
161
+ expect(chunk.type).to eq(:text_delta)
162
+ expect(chunk.delta).to eq('hello')
96
163
 
97
- expect do
98
- chunk = provider.send(:build_chunk, stream_delta(tool_calls: [tool_call_delta_without_name]))
99
- end.not_to raise_error
164
+ usage_chunk = provider.send(:build_chunk, { 'choices' => [], 'usage' => { 'prompt_tokens' => 2 } })
165
+ expect(usage_chunk.type).to eq(:usage)
166
+ expect(usage_chunk.usage.input_tokens).to eq(2)
167
+ end
100
168
 
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('{}')
169
+ it 'emits tool_call_delta chunks carrying the wire fragment (U1/U2 shapes)' do
170
+ chunk = provider.send(:build_chunk, stream_delta(tool_calls: [tool_call_delta_without_name]))
171
+ expect(chunk).to be_a(canonical::Chunk)
172
+ expect(chunk.type).to eq(:tool_call_delta)
173
+ expect(chunk.tool_call).to eq(id: nil, name: nil, arguments: '{}', index: 0)
104
174
  end
105
175
 
106
176
  it 'accumulates streamed OpenAI-compatible tool call argument fragments' do
@@ -110,52 +180,51 @@ RSpec.describe Legion::Extensions::Llm::Provider::OpenAICompatible do
110
180
  accumulator.add(provider.send(:build_chunk, stream_delta(tool_calls: [delta])))
111
181
  end
112
182
 
113
- message = accumulator.to_message(nil)
114
- expect(message.tool_calls['call-1'].arguments).to eq('city' => 'Chicago')
183
+ message = accumulator.to_response(model: model_id)
184
+ expect(message.tool_calls.first.name).to eq('lookup')
185
+ expect(message.tool_calls.first.arguments).to eq('city' => 'Chicago')
115
186
  end
116
187
 
117
188
  it 'renders embedding payloads with model ids' do
118
- payload = provider.send(:render_embedding_payload, 'hello', model: model, dimensions: 768)
189
+ payload = provider.send(:render_embedding_payload, 'hello', model: model_id, dimensions: 768)
119
190
 
120
- expect(payload).to eq(model: 'model-a', input: 'hello', dimensions: 768)
191
+ expect(payload).to eq(model: model_id, input: 'hello', dimensions: 768)
121
192
  end
122
193
 
123
- it 'parses embedding responses for single and batch inputs' do
194
+ it 'parses embedding responses to the documented artifact Hash (05 §3)' do
124
195
  single = provider.send(:parse_embedding_response, fake_response(embedding_body), model: 'embed', text: 'a')
125
196
  batch = provider.send(:parse_embedding_response, fake_response(embedding_body), model: 'embed', text: %w[a b])
126
197
 
127
- expect([single.vectors, batch.vectors]).to eq([[0.1], [[0.1], [0.2]]])
198
+ expect(single[:embedding]).to eq([0.1])
199
+ expect(batch[:embedding]).to eq([[0.1], [0.2]])
200
+ expect(single[:usage]).to be_a(canonical::Usage)
128
201
  end
129
202
 
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)
203
+ it 'parses moderation responses to the documented artifact Hash (05 §3)' do
204
+ body = { 'model' => 'mod-1', 'results' => [{ 'flagged' => true, 'categories' => { 'hate' => true } }] }
205
+ result = provider.send(:parse_moderation_response, fake_response(body), model: 'mod-1')
133
206
 
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
- ])
207
+ expect(result).to eq(model: 'mod-1', result: { flagged: true, categories: { 'hate' => true } })
139
208
  end
140
209
 
141
210
  def chat_payload
142
- message = Legion::Extensions::Llm::Message.new(role: :user, content: 'hello')
211
+ message = canonical::Message.build(role: :user, content: 'hello')
143
212
  provider.send(
144
213
  :render_payload,
145
214
  [message],
146
215
  tools: {},
147
- temperature: 0.2,
148
- model: model,
216
+ model: model_id,
149
217
  stream: false,
150
218
  schema: nil,
151
219
  thinking: nil,
152
- tool_prefs: nil
220
+ tool_prefs: nil,
221
+ params: canonical::Params.build(temperature: 0.2)
153
222
  )
154
223
  end
155
224
 
156
225
  def completion_body(content: 'hi')
157
226
  {
158
- 'model' => 'model-a',
227
+ 'model' => model_id,
159
228
  'choices' => [{ 'message' => { 'content' => content, 'tool_calls' => [tool_call] } }],
160
229
  'usage' => { 'prompt_tokens' => 3, 'completion_tokens' => 5 }
161
230
  }
@@ -177,26 +246,17 @@ RSpec.describe Legion::Extensions::Llm::Provider::OpenAICompatible do
177
246
  end
178
247
 
179
248
  def think_tag_stream
180
- ['<think>', 'internal', '</think>visible']
249
+ ['<thinking>', 'internal', '</thinking>visible']
181
250
  end
182
251
 
183
252
  def stream_delta(content: nil, tool_calls: nil)
184
- { 'model' => 'model-a', 'choices' => [{ 'delta' => { 'content' => content, 'tool_calls' => tool_calls }.compact }] }
253
+ { 'model' => model_id, 'choices' => [{ 'delta' => { 'content' => content, 'tool_calls' => tool_calls }.compact }] }
185
254
  end
186
255
 
187
256
  def embedding_body
188
257
  { 'data' => [{ 'embedding' => [0.1] }, { 'embedding' => [0.2] }], 'usage' => { 'prompt_tokens' => 2 } }
189
258
  end
190
259
 
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
260
  def fake_response(body)
201
261
  Struct.new(:body).new(body)
202
262
  end