lex-llm 0.7.3 → 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 (163) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +106 -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 -153
  29. data/lib/legion/extensions/llm/fleet/token_validator.rb +15 -21
  30. data/lib/legion/extensions/llm/fleet/worker_execution.rb +107 -150
  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 +82 -99
  37. data/lib/legion/extensions/llm/inventory/registry.rb +54 -46
  38. data/lib/legion/extensions/llm/inventory/snapshot.rb +7 -21
  39. data/lib/legion/extensions/llm/inventory/weight_reconciler.rb +249 -0
  40. data/lib/legion/extensions/llm/inventory/weight_schema.rb +146 -0
  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 +29 -17
  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 +10 -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 +178 -67
  82. data/spec/legion/extensions/llm/fleet/token_validator_spec.rb +7 -2
  83. data/spec/legion/extensions/llm/fleet/worker_execution_spec.rb +101 -62
  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 +129 -41
  90. data/spec/legion/extensions/llm/inventory/registry_activation_spec.rb +55 -4
  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 +312 -0
  94. data/spec/legion/extensions/llm/inventory/weight_schema_spec.rb +141 -0
  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 -16
  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. data/spec/support/ssot_registry_helpers.rb +3 -2
  110. metadata +15 -54
  111. data/lib/legion/extensions/llm/agent.rb +0 -366
  112. data/lib/legion/extensions/llm/aliases.json +0 -436
  113. data/lib/legion/extensions/llm/aliases.rb +0 -67
  114. data/lib/legion/extensions/llm/attachment.rb +0 -229
  115. data/lib/legion/extensions/llm/chat.rb +0 -354
  116. data/lib/legion/extensions/llm/chunk.rb +0 -10
  117. data/lib/legion/extensions/llm/content.rb +0 -81
  118. data/lib/legion/extensions/llm/context.rb +0 -33
  119. data/lib/legion/extensions/llm/embedding.rb +0 -33
  120. data/lib/legion/extensions/llm/image.rb +0 -109
  121. data/lib/legion/extensions/llm/inventory/capabilities.rb +0 -40
  122. data/lib/legion/extensions/llm/inventory/scoped_refresher.rb +0 -311
  123. data/lib/legion/extensions/llm/message.rb +0 -118
  124. data/lib/legion/extensions/llm/mime_type.rb +0 -75
  125. data/lib/legion/extensions/llm/model/info.rb +0 -286
  126. data/lib/legion/extensions/llm/model/modalities.rb +0 -26
  127. data/lib/legion/extensions/llm/model/pricing.rb +0 -52
  128. data/lib/legion/extensions/llm/model/pricing_category.rb +0 -50
  129. data/lib/legion/extensions/llm/model/pricing_tier.rb +0 -37
  130. data/lib/legion/extensions/llm/model.rb +0 -11
  131. data/lib/legion/extensions/llm/models.json +0 -57313
  132. data/lib/legion/extensions/llm/models.rb +0 -530
  133. data/lib/legion/extensions/llm/models_schema.json +0 -168
  134. data/lib/legion/extensions/llm/moderation.rb +0 -60
  135. data/lib/legion/extensions/llm/registry_event_builder.rb +0 -141
  136. data/lib/legion/extensions/llm/registry_publisher.rb +0 -107
  137. data/lib/legion/extensions/llm/responses/chat_response.rb +0 -43
  138. data/lib/legion/extensions/llm/responses/embedding_response.rb +0 -38
  139. data/lib/legion/extensions/llm/responses/stream_chunk.rb +0 -43
  140. data/lib/legion/extensions/llm/routing/lane_key.rb +0 -66
  141. data/lib/legion/extensions/llm/routing/model_offering.rb +0 -241
  142. data/lib/legion/extensions/llm/routing/offering_registry.rb +0 -101
  143. data/lib/legion/extensions/llm/routing/registry_event.rb +0 -167
  144. data/lib/legion/extensions/llm/thinking.rb +0 -53
  145. data/lib/legion/extensions/llm/tokens.rb +0 -51
  146. data/lib/legion/extensions/llm/tool_call.rb +0 -34
  147. data/lib/legion/extensions/llm/transcription.rb +0 -39
  148. data/lib/legion/extensions/llm/transport/messages/registry_event.rb +0 -44
  149. data/spec/legion/extensions/llm/agent_spec.rb +0 -179
  150. data/spec/legion/extensions/llm/attachment_spec.rb +0 -25
  151. data/spec/legion/extensions/llm/conformance/fixtures/ssot_identity_vectors.json +0 -84
  152. data/spec/legion/extensions/llm/context_spec.rb +0 -127
  153. data/spec/legion/extensions/llm/inventory/capabilities_spec.rb +0 -43
  154. data/spec/legion/extensions/llm/inventory/scoped_refresher_spec.rb +0 -337
  155. data/spec/legion/extensions/llm/message_spec.rb +0 -64
  156. data/spec/legion/extensions/llm/model/info_spec.rb +0 -222
  157. data/spec/legion/extensions/llm/models_spec.rb +0 -104
  158. data/spec/legion/extensions/llm/registry_event_builder_spec.rb +0 -68
  159. data/spec/legion/extensions/llm/registry_publisher_spec.rb +0 -22
  160. data/spec/legion/extensions/llm/responses/response_objects_spec.rb +0 -75
  161. data/spec/legion/extensions/llm/routing/model_offering_spec.rb +0 -281
  162. data/spec/legion/extensions/llm/routing/offering_registry_spec.rb +0 -50
  163. data/spec/legion/extensions/llm/routing/registry_event_spec.rb +0 -120
@@ -1,8 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'spec_helper'
4
+ require 'support/ssot_registry_helpers'
4
5
 
5
6
  RSpec.describe Legion::Extensions::Llm::Provider do
7
+ include SsotRegistryHelpers
8
+
6
9
  describe 'Hash config support' do
7
10
  let(:provider_class) do
8
11
  Class.new(described_class) do
@@ -100,38 +103,19 @@ RSpec.describe Legion::Extensions::Llm::Provider do
100
103
  end
101
104
 
102
105
  describe 'canonical provider contract' do
103
- let(:model) do
104
- Legion::Extensions::Llm::Model::Info.new(
105
- id: 'test-model',
106
- provider: :contract,
107
- instance: :primary,
108
- capabilities: %i[completion streaming tools],
109
- context_length: 8192,
110
- metadata: { max_output_tokens: 2048 }
111
- )
112
- end
113
-
114
106
  let(:provider_class) do
115
- model_info = model
116
107
  Class.new(described_class) do
117
108
  def self.name = 'Provider'
118
109
 
119
110
  define_method(:api_base) { 'https://contract.invalid' }
120
111
  define_method(:models_url) { '/v1/models' }
121
- attr_reader :list_model_calls
122
-
123
- define_method(:list_models) do |live: false, **filters|
124
- @list_model_calls ||= []
125
- @list_model_calls << { live: live, filters: filters }
126
- [model_info]
127
- end
128
112
 
129
113
  def render_payload(_messages, **)
130
114
  {}
131
115
  end
132
116
 
133
117
  def parse_completion_response(_response)
134
- Legion::Extensions::Llm::Message.new(role: :assistant, content: 'ok')
118
+ Legion::Extensions::Llm::Canonical::Response.build(text: 'ok')
135
119
  end
136
120
  end
137
121
  end
@@ -143,12 +127,12 @@ RSpec.describe Legion::Extensions::Llm::Provider do
143
127
  instance_id: :primary })
144
128
  end
145
129
 
146
- it 'exposes a canonical chat alias over complete' do
130
+ it 'exposes a canonical chat alias over complete (0.8.0 signature — O4 temperature in Params)' do
147
131
  allow(provider).to receive(:complete).and_return('ok')
148
132
 
149
- expect(provider.chat(messages: [], model: model)).to eq('ok')
133
+ expect(provider.chat([], model: 'test-model')).to eq('ok')
150
134
  expect(provider).to have_received(:complete).with(
151
- [], tools: [], temperature: nil, model: model, params: {}, headers: {},
135
+ [], tools: [], model: 'test-model', params: nil, headers: {},
152
136
  schema: nil, thinking: nil, tool_prefs: nil
153
137
  )
154
138
  end
@@ -157,73 +141,86 @@ RSpec.describe Legion::Extensions::Llm::Provider do
157
141
  seen = []
158
142
  allow(provider).to receive(:complete) { |_messages, **_opts, &block| block.call('chunk') }
159
143
 
160
- provider.stream_chat(messages: [], model: model) { |chunk| seen << chunk }
144
+ provider.stream_chat([], model: 'test-model') { |chunk| seen << chunk }
161
145
 
162
146
  expect(seen).to eq(['chunk'])
163
147
  end
164
148
 
165
- it 'converts live list_models results into model offerings' do
166
- offerings = provider.discover_offerings(live: true)
167
- offering = offerings.first
168
-
169
- expect(offerings.size).to eq(1)
170
- expect(offering.provider_family).to eq(:provider)
171
- expect(offering.provider_instance).to eq(:primary)
172
- expect(offering.model).to eq('test-model')
173
- expect(offering.usage_type).to eq(:inference)
174
- expect(offering.capabilities).to include(:completion, :streaming, :tools)
175
- expect(offering.context_window).to eq(8192)
176
- end
177
-
178
- it 'publishes every discovered model before policy filtering removes blocked models' do
179
- blocked_model = Legion::Extensions::Llm::Model::Info.new(
180
- id: 'blocked-model',
181
- provider: :contract,
182
- instance: :primary,
183
- capabilities: %i[completion],
184
- context_length: 4096
149
+ # 0.8.0 (07 C5): the legacy ModelOffering production path is deleted — the
150
+ # per-gem writer publishes; the base read path serves the activated
151
+ # inventory offerings for this instance from the registry snapshot.
152
+ def activate_contract_instance
153
+ key = Legion::Extensions::Llm::Inventory::Identity::InstanceKey.new(
154
+ provider_family: 'provider', instance_id: 'primary'
185
155
  )
186
- registry_publisher = instance_double(Legion::Extensions::Llm::RegistryPublisher)
187
- allow(registry_publisher).to receive(:publish_models_async)
188
- allow(provider_class).to receive(:registry_publisher).and_return(registry_publisher)
189
- allow(provider).to receive_messages(list_models: [model, blocked_model], settings: { model_blacklist: ['blocked'] })
190
-
191
- offerings = provider.discover_offerings(live: true)
192
-
193
- expect(offerings.map(&:model)).to eq(['test-model'])
194
- expect(registry_publisher).to have_received(:publish_models_async).with([model], anything)
195
- expect(registry_publisher).to have_received(:publish_models_async).with([blocked_model], anything)
156
+ Legion::Extensions::Llm::Inventory::Registry.reset!
157
+ claim_and_activate(key: key, callable: Object.new, coordinator: probe_coordinator(key), model: 'test-model')
196
158
  end
197
159
 
198
- it 'passes live discovery filters through to list_models' do
199
- provider.discover_offerings(live: true, capability: :tools, instance: :primary)
160
+ it 'serves the activated inventory offerings for the instance from the registry snapshot (07 C5)' do
161
+ activate_contract_instance
162
+ offerings = provider.discover_offerings
200
163
 
201
- expect(provider.list_model_calls).to include(
202
- live: true,
203
- filters: { capability: :tools, instance: :primary }
204
- )
164
+ expect(offerings.size).to eq(1)
165
+ expect(offerings.first.model).to eq('test-model')
166
+ expect(offerings.first.instance_key.instance_id).to eq('primary')
167
+ expect(offerings.first.instance_key.provider_family).to eq(:provider)
205
168
  end
206
169
 
207
- it 'filters generated offerings by capability and instance' do
208
- provider.discover_offerings(live: true)
170
+ it 'filters snapshot offerings by model and instance keys' do
171
+ activate_contract_instance
209
172
 
210
- expect(provider.discover_offerings(capability: :tools, instance: :primary)).not_to be_empty
211
- expect(provider.discover_offerings(capability: :embedding)).to be_empty
212
- expect(provider.discover_offerings(instance: :other)).to be_empty
173
+ expect(provider.discover_offerings(model: 'test-model')).not_to be_empty
174
+ expect(provider.discover_offerings(id: 'test-model')).not_to be_empty
175
+ expect(provider.discover_offerings(model: 'other-model')).to be_empty
176
+ expect(provider.discover_offerings(instance: 'other')).to be_empty
213
177
  end
214
178
 
215
- it 'does not perform live discovery for uncached non-live offerings reads' do
179
+ it 'never calls list_models — the production path moved to the per-gem writer' do
216
180
  allow(provider).to receive(:list_models).and_raise('unexpected live discovery')
217
181
 
218
- expect(provider.discover_offerings).to eq([])
182
+ expect { provider.discover_offerings(live: true) }.not_to raise_error
219
183
  expect(provider).not_to have_received(:list_models)
220
184
  end
221
185
 
222
- it 'serves non-live offerings reads from the live discovery cache' do
223
- provider.discover_offerings(live: true)
224
- allow(provider).to receive(:list_models).and_raise('unexpected live discovery')
186
+ describe 'H5 — honest read-path contract (accepted-for-compat params are documented, not silently discarded)' do
187
+ it 'accepts the full discover_offerings signature against the snapshot' do
188
+ expect { provider.discover_offerings(live: true, raise_on_unreachable: true) }.not_to raise_error
189
+ expect(provider.discover_offerings(raise_on_unreachable: true, model: 'test-model').size).to eq(1)
190
+ end
191
+ end
192
+
193
+ describe 'H3 — the funnel enforces the tool contract (one contract, one oracle)' do
194
+ let(:tool) { Legion::Extensions::Llm::Canonical::ToolDefinition.build(name: 'get_weather') }
195
+
196
+ before do
197
+ allow(provider).to receive(:sync_response).and_return('ok') # -- stream_response's model: kwarg is not exercised here
198
+ allow(provider).to receive(:stream_response) { |_connection, _payload, _headers, **_opts, &stream_block| stream_block&.call('c') }
199
+ end
200
+
201
+ it 'accepts Hash<name, Canonical::ToolDefinition> through chat and stream_chat' do
202
+ expect(provider.chat([], model: 'test-model', tools: { get_weather: tool })).to eq('ok')
203
+ expect do
204
+ # rubocop:disable Lint/EmptyBlock -- the streamed chunk is consumed by the stub
205
+ provider.stream_chat([], model: 'test-model', tools: { get_weather: tool }) { |_c| }
206
+ # rubocop:enable Lint/EmptyBlock
207
+ end.not_to raise_error
208
+ end
209
+
210
+ it 'rejects Hash tool values with a typed ArgumentError' do
211
+ expect { provider.chat([], model: 'test-model', tools: { get_weather: { name: 'get_weather' } }) }
212
+ .to raise_error(ArgumentError, /Canonical::ToolDefinition/)
213
+ end
225
214
 
226
- expect(provider.discover_offerings(capability: :tools, instance: :primary)).not_to be_empty
215
+ it 'rejects a non-Hash tools container with a typed ArgumentError' do
216
+ expect { provider.chat([], model: 'test-model', tools: [tool]) }
217
+ .to raise_error(ArgumentError, /Hash<name, Canonical::ToolDefinition>/)
218
+ end
219
+
220
+ it 'rejects legacy tool objects with a typed ArgumentError' do
221
+ expect { provider.chat([], model: 'test-model', tools: { get_weather: Legion::Extensions::Llm::Tool.new }) }
222
+ .to raise_error(ArgumentError, /Canonical::ToolDefinition/)
223
+ end
227
224
  end
228
225
 
229
226
  it 'returns normalized health metadata' do
@@ -237,16 +234,17 @@ RSpec.describe Legion::Extensions::Llm::Provider do
237
234
  end
238
235
 
239
236
  it 'provides a deterministic token estimate fallback' do
240
- expect(provider.count_tokens(messages: [{ content: 'hello world' }], model: model)).to be >= 1
237
+ messages = [Legion::Extensions::Llm::Canonical::Message.build(role: :user, content: 'hello world')]
238
+ expect(provider.count_tokens(messages: messages, model: 'test-model')).to be >= 1
241
239
  end
242
240
 
243
- it 'summarizes hash-backed tools for debug logging' do
241
+ it 'summarizes canonical tools for debug logging' do
244
242
  tools = {
245
- current: { name: 'current' },
246
- legacy: { 'name' => 'legacy' }
243
+ current: Legion::Extensions::Llm::Canonical::ToolDefinition.build(name: 'current'),
244
+ other: Legion::Extensions::Llm::Canonical::ToolDefinition.build(name: 'other')
247
245
  }
248
246
 
249
- expect(provider.send(:debug_tool_names, tools)).to eq(%w[current legacy])
247
+ expect(provider.send(:debug_tool_names, tools)).to eq(%w[current other])
250
248
  end
251
249
 
252
250
  it 'deep merges embedding params into the provider payload' do
@@ -375,6 +373,28 @@ RSpec.describe Legion::Extensions::Llm::Provider do
375
373
  expect(provider.model_allowed?('gpt-5')).to be true
376
374
  end
377
375
  end
376
+
377
+ context 'when the model is an object exposing #id' do
378
+ let(:model_info) do
379
+ Struct.new(:id).new('claude-sonnet-4-6')
380
+ end
381
+
382
+ before { provider.settings = { model_whitelist: %w[sonnet] } }
383
+
384
+ it 'matches policy against the model id, not the inspect string' do
385
+ expect(provider.model_allowed?(model_info)).to be true
386
+ end
387
+
388
+ it 'blocks object models the whitelist does not cover' do
389
+ other = Struct.new(:id).new('llama-3')
390
+ expect(provider.model_allowed?(other)).to be false
391
+ end
392
+
393
+ it 'never matches inspect-string artifacts (e.g. a data/name pattern)' do
394
+ provider.settings = { model_whitelist: %w[data] }
395
+ expect(provider.model_allowed?(model_info)).to be false
396
+ end
397
+ end
378
398
  end
379
399
 
380
400
  describe '#enforce_model_allowed! (dispatch compliance guard)' do
@@ -407,7 +427,7 @@ RSpec.describe Legion::Extensions::Llm::Provider do
407
427
  end
408
428
 
409
429
  it 'fails closed in #complete before any provider call' do
410
- expect { provider.complete([], tools: [], temperature: nil, model: 'gpt-5') }
430
+ expect { provider.complete([], tools: [], model: 'gpt-5') }
411
431
  .to raise_error(Legion::Extensions::Llm::ModelNotAllowedError)
412
432
  end
413
433
 
@@ -421,7 +441,7 @@ RSpec.describe Legion::Extensions::Llm::Provider do
421
441
  before { provider.settings = { model_blacklist: %w[sonnet] } }
422
442
 
423
443
  it 'fails closed in #complete for a blacklisted model' do
424
- expect { provider.complete([], tools: [], temperature: nil, model: 'claude-sonnet-4-6') }
444
+ expect { provider.complete([], tools: [], model: 'claude-sonnet-4-6') }
425
445
  .to raise_error(Legion::Extensions::Llm::ModelNotAllowedError)
426
446
  end
427
447
  end
@@ -16,9 +16,11 @@ RSpec.describe Legion::Extensions::Llm::Routing::Records do
16
16
  end
17
17
  let(:callable_handle) { inventory::CallableHandle.new(handle_id: 'call:v1:x', callable: callable_class.new) }
18
18
  let(:instance_key) { identity::InstanceKey.new(provider_family: 'vllm', instance_id: 'h200') }
19
- let(:offering_id) { identity.offering_id(instance_key: instance_key, provider_native_key: 'gemma4') }
20
19
  let(:lane_id) do
21
- identity.lane_id(instance_key: instance_key, operation: :chat, model: 'gemma4', offering_id: offering_id)
20
+ identity.compose_lane_id(
21
+ tier: :local, provider_family: 'vllm', instance_id: 'h200',
22
+ type: Legion::Extensions::Llm::Taxonomies.lane_type_for(operation: :chat), model: 'gemma4'
23
+ )
22
24
  end
23
25
 
24
26
  describe routing::AttemptTargetKey do
@@ -76,7 +78,7 @@ RSpec.describe Legion::Extensions::Llm::Routing::Records do
76
78
  describe routing::Selection do
77
79
  def build_selection(**overrides)
78
80
  Legion::Extensions::Llm::Routing::Selection.new(
79
- inventory_generation: 7, lane_id: lane_id, instance_key: instance_key, offering_id: offering_id,
81
+ inventory_generation: 7, lane_id: lane_id, instance_key: instance_key,
80
82
  provider_family: :vllm, instance_id: 'h200', model: 'gemma4', operation: :chat,
81
83
  callable_handle: callable_handle, publisher_token_id: 'ptok:v1:abc', capability_evidence: {},
82
84
  context_evidence: Legion::Extensions::Llm::Inventory::ValueEvidence.new(status: :unknown, source: :absent),
@@ -88,6 +90,8 @@ RSpec.describe Legion::Extensions::Llm::Routing::Records do
88
90
 
89
91
  it 'builds a valid selection and derives the attempt target key' do
90
92
  selection = build_selection
93
+ expect(selection.lane_id).to eq('local:vllm:h200:inference:gemma4')
94
+ expect(selection.members).not_to include(:offering_id)
91
95
  key = selection.attempt_target_key
92
96
  expect(key).to eq(routing::AttemptTargetKey.new(provider_family: :vllm, instance_id: 'h200', model: 'gemma4'))
93
97
  end
@@ -105,8 +109,37 @@ RSpec.describe Legion::Extensions::Llm::Routing::Records do
105
109
  .to raise_error(errors::ValidationError)
106
110
  end
107
111
 
108
- it 'rejects a lane_id that does not reproduce' do
109
- expect { build_selection(lane_id: "lane:v1:#{'0' * 64}") }.to raise_error(errors::ValidationError)
112
+ it 'rejects a lane_id that is not a 5-tuple' do
113
+ expect { build_selection(lane_id: 'not-a-five-tuple') }
114
+ .to raise_error(errors::ValidationError)
115
+ end
116
+
117
+ it 'rejects a lane_id whose family part disagrees with the selection' do
118
+ expect { build_selection(lane_id: 'local:openai:h200:inference:gemma4') }
119
+ .to raise_error(errors::ValidationError, /provider_family/)
120
+ end
121
+
122
+ it 'rejects a lane_id whose instance part disagrees with the selection' do
123
+ expect { build_selection(lane_id: 'local:vllm:helios1:inference:gemma4') }
124
+ .to raise_error(errors::ValidationError, /instance_id/)
125
+ end
126
+
127
+ it 'rejects a lane_id whose type part disagrees with the operation' do
128
+ expect { build_selection(lane_id: 'local:vllm:h200:embedding:gemma4') }
129
+ .to raise_error(errors::ValidationError, /type/)
130
+ end
131
+
132
+ it 'rejects a lane_id whose model part disagrees with the selection' do
133
+ expect { build_selection(lane_id: 'local:vllm:h200:inference:other-model') }
134
+ .to raise_error(errors::ValidationError, /model/)
135
+ end
136
+
137
+ it 'keeps colons inside the lane id model part' do
138
+ colon_model = 'llama3.2:8b-instruct'
139
+ id = identity.compose_lane_id(
140
+ tier: :local, provider_family: 'vllm', instance_id: 'h200', type: :inference, model: colon_model
141
+ )
142
+ expect(build_selection(lane_id: id, model: colon_model).lane_id).to eq(id)
110
143
  end
111
144
 
112
145
  it 'rejects a publisher_token_id without the ptok:v1: prefix' do