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
@@ -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
@@ -10,22 +10,19 @@ RSpec.describe Legion::Extensions::Llm::ProviderContract do
10
10
  def self.configuration_requirements = []
11
11
  def self.local? = false
12
12
  def self.remote? = true
13
- def self.assume_models_exist? = false
14
13
 
15
14
  def ensure_configured! = nil
16
15
  def api_base = 'http://example.invalid'
17
16
  def models_url = '/v1/models'
18
- def parse_list_models_response(*) = []
19
17
  end
20
18
  end
21
19
 
22
- it 'requires canonical keyword provider methods' do
20
+ it 'requires the 0.8.0 provider method signatures' do
23
21
  expected = {
24
- chat: [%i[keyreq messages], %i[keyreq model]],
25
- stream_chat: [%i[keyreq messages], %i[keyreq model]],
22
+ chat: [%i[req messages], %i[keyreq model]],
23
+ stream_chat: [%i[req messages], %i[keyreq model]],
26
24
  embed: [%i[keyreq text], %i[keyreq model]],
27
25
  image: [%i[keyreq prompt], %i[keyreq model]],
28
- list_models: [%i[key live], %i[keyrest filters]],
29
26
  discover_offerings: [%i[key live], %i[keyrest filters]],
30
27
  health: [%i[key live]],
31
28
  count_tokens: [%i[keyreq messages], %i[keyreq model], %i[key params]]
@@ -36,13 +33,12 @@ RSpec.describe Legion::Extensions::Llm::ProviderContract do
36
33
  required_parameters.each do |required_parameter|
37
34
  expect(parameters).to include(required_parameter), "#{method_name} missing #{required_parameter.inspect}"
38
35
  end
39
- expect(parameters).not_to include(%i[req messages])
40
36
  expect(parameters).not_to include(%i[req text])
41
37
  expect(parameters).not_to include(%i[req prompt])
42
38
  end
43
39
  end
44
40
 
45
- it 'rejects positional canonical arguments' do
41
+ it 'rejects non-canonical message input and positional artifact arguments' do
46
42
  provider = provider_class.new(
47
43
  request_timeout: 30,
48
44
  max_retries: 0,
@@ -51,20 +47,19 @@ RSpec.describe Legion::Extensions::Llm::ProviderContract do
51
47
  retry_interval_randomness: 0
52
48
  )
53
49
 
54
- expect { provider.chat([], model: 'model') }.to raise_error(ArgumentError)
55
- expect { provider.stream_chat([], model: 'model') }.to raise_error(ArgumentError)
50
+ expect { provider.chat(['not canonical'], model: 'model') }.to raise_error(ArgumentError, /Canonical::Message/)
51
+ expect { provider.stream_chat([42], model: 'model') }.to raise_error(ArgumentError, /Canonical::Message/)
52
+ expect { provider.count_tokens(messages: ['nope'], model: 'model') }.to raise_error(ArgumentError, /Canonical::Message/)
56
53
  expect { provider.embed('text', model: 'model') }.to raise_error(ArgumentError)
57
54
  expect { provider.image('prompt', model: 'model') }.to raise_error(ArgumentError)
58
- expect { provider.count_tokens([], model: 'model') }.to raise_error(ArgumentError)
59
55
  end
60
56
 
61
- it 'keeps REQUIRED_SIGNATURES as exactly the eight-entry reflection baseline' do
57
+ it 'keeps REQUIRED_SIGNATURES as exactly the seven-entry reflection baseline' do
62
58
  expect(described_class::REQUIRED_SIGNATURES).to eq(
63
- chat: [%i[keyreq messages], %i[keyreq model]],
64
- stream_chat: [%i[keyreq messages], %i[keyreq model]],
59
+ chat: [%i[req messages], %i[keyreq model]],
60
+ stream_chat: [%i[req messages], %i[keyreq model]],
65
61
  embed: [%i[keyreq text], %i[keyreq model]],
66
62
  image: [%i[keyreq prompt], %i[keyreq model]],
67
- list_models: [%i[key live], %i[keyrest filters]],
68
63
  discover_offerings: [%i[key live], %i[key raise_on_unreachable], %i[keyrest filters]],
69
64
  health: [%i[key live]],
70
65
  count_tokens: [%i[keyreq messages], %i[keyreq model], %i[key params]]
@@ -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,85 @@ 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-next Lint/EmptyBlock -- the streamed chunk is consumed by the stub
205
+ provider.stream_chat([], model: 'test-model', tools: { get_weather: tool }) { |_c| }
206
+ end.not_to raise_error
207
+ end
208
+
209
+ it 'rejects Hash tool values with a typed ArgumentError' do
210
+ expect { provider.chat([], model: 'test-model', tools: { get_weather: { name: 'get_weather' } }) }
211
+ .to raise_error(ArgumentError, /Canonical::ToolDefinition/)
212
+ end
225
213
 
226
- expect(provider.discover_offerings(capability: :tools, instance: :primary)).not_to be_empty
214
+ it 'rejects a non-Hash tools container with a typed ArgumentError' do
215
+ expect { provider.chat([], model: 'test-model', tools: [tool]) }
216
+ .to raise_error(ArgumentError, /Hash<name, Canonical::ToolDefinition>/)
217
+ end
218
+
219
+ it 'rejects legacy tool objects with a typed ArgumentError' do
220
+ expect { provider.chat([], model: 'test-model', tools: { get_weather: Legion::Extensions::Llm::Tool.new }) }
221
+ .to raise_error(ArgumentError, /Canonical::ToolDefinition/)
222
+ end
227
223
  end
228
224
 
229
225
  it 'returns normalized health metadata' do
@@ -237,16 +233,17 @@ RSpec.describe Legion::Extensions::Llm::Provider do
237
233
  end
238
234
 
239
235
  it 'provides a deterministic token estimate fallback' do
240
- expect(provider.count_tokens(messages: [{ content: 'hello world' }], model: model)).to be >= 1
236
+ messages = [Legion::Extensions::Llm::Canonical::Message.build(role: :user, content: 'hello world')]
237
+ expect(provider.count_tokens(messages: messages, model: 'test-model')).to be >= 1
241
238
  end
242
239
 
243
- it 'summarizes hash-backed tools for debug logging' do
240
+ it 'summarizes canonical tools for debug logging' do
244
241
  tools = {
245
- current: { name: 'current' },
246
- legacy: { 'name' => 'legacy' }
242
+ current: Legion::Extensions::Llm::Canonical::ToolDefinition.build(name: 'current'),
243
+ other: Legion::Extensions::Llm::Canonical::ToolDefinition.build(name: 'other')
247
244
  }
248
245
 
249
- expect(provider.send(:debug_tool_names, tools)).to eq(%w[current legacy])
246
+ expect(provider.send(:debug_tool_names, tools)).to eq(%w[current other])
250
247
  end
251
248
 
252
249
  it 'deep merges embedding params into the provider payload' do
@@ -375,6 +372,28 @@ RSpec.describe Legion::Extensions::Llm::Provider do
375
372
  expect(provider.model_allowed?('gpt-5')).to be true
376
373
  end
377
374
  end
375
+
376
+ context 'when the model is an object exposing #id' do
377
+ let(:model_info) do
378
+ Struct.new(:id).new('claude-sonnet-4-6')
379
+ end
380
+
381
+ before { provider.settings = { model_whitelist: %w[sonnet] } }
382
+
383
+ it 'matches policy against the model id, not the inspect string' do
384
+ expect(provider.model_allowed?(model_info)).to be true
385
+ end
386
+
387
+ it 'blocks object models the whitelist does not cover' do
388
+ other = Struct.new(:id).new('llama-3')
389
+ expect(provider.model_allowed?(other)).to be false
390
+ end
391
+
392
+ it 'never matches inspect-string artifacts (e.g. a data/name pattern)' do
393
+ provider.settings = { model_whitelist: %w[data] }
394
+ expect(provider.model_allowed?(model_info)).to be false
395
+ end
396
+ end
378
397
  end
379
398
 
380
399
  describe '#enforce_model_allowed! (dispatch compliance guard)' do
@@ -407,7 +426,7 @@ RSpec.describe Legion::Extensions::Llm::Provider do
407
426
  end
408
427
 
409
428
  it 'fails closed in #complete before any provider call' do
410
- expect { provider.complete([], tools: [], temperature: nil, model: 'gpt-5') }
429
+ expect { provider.complete([], tools: [], model: 'gpt-5') }
411
430
  .to raise_error(Legion::Extensions::Llm::ModelNotAllowedError)
412
431
  end
413
432
 
@@ -421,7 +440,7 @@ RSpec.describe Legion::Extensions::Llm::Provider do
421
440
  before { provider.settings = { model_blacklist: %w[sonnet] } }
422
441
 
423
442
  it 'fails closed in #complete for a blacklisted model' do
424
- expect { provider.complete([], tools: [], temperature: nil, model: 'claude-sonnet-4-6') }
443
+ expect { provider.complete([], tools: [], model: 'claude-sonnet-4-6') }
425
444
  .to raise_error(Legion::Extensions::Llm::ModelNotAllowedError)
426
445
  end
427
446
  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