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
@@ -36,11 +36,21 @@ RSpec.describe Legion::Extensions::Llm::Fleet::ExactOffering do
36
36
 
37
37
  def disconnect; end
38
38
 
39
- def chat(messages:, model:, **rest)
39
+ def chat(messages, model:, **rest)
40
40
  @calls << { op: :chat, messages: messages, model: model, rest: rest }
41
41
  { content: 'exact-ok' }
42
42
  end
43
43
 
44
+ def stream_chat(messages, model:, **rest)
45
+ @calls << { op: :stream_chat, messages: messages, model: model, rest: rest }
46
+ { content: 'stream-exact-ok' }
47
+ end
48
+
49
+ def count_tokens(messages:, model:, **rest)
50
+ @calls << { op: :count_tokens, messages: messages, model: model, rest: rest }
51
+ 1
52
+ end
53
+
44
54
  def embed(text:, model:, **rest)
45
55
  @calls << { op: :embed, text: text, model: model, rest: rest }
46
56
  { content: 'embed-ok' }
@@ -52,12 +62,17 @@ RSpec.describe Legion::Extensions::Llm::Fleet::ExactOffering do
52
62
 
53
63
  before do
54
64
  inventory::Registry.reset!
55
- claim_and_activate(key: key, callable: recording_callable, coordinator: probe_coordinator(key))
65
+ claim_and_activate(
66
+ key: key, callable: recording_callable, coordinator: probe_coordinator(key),
67
+ supported: %i[chat stream_chat count_tokens]
68
+ )
56
69
  allow(worker).to receive_messages(validate_identity!: true, validate_idempotency!: nil)
57
70
  end
58
71
 
72
+ # The claim field keeps the name offering_id (D4); its value is the
73
+ # activated inference lane's 5 tuple.
59
74
  def offering_id
60
- Legion::Extensions::Llm::Inventory::Identity.offering_id(instance_key: key, provider_native_key: 'gemma4')
75
+ Legion::Extensions::Llm::Inventory::Registry.snapshot.lanes_for(instance_key: key).first.lane_id
61
76
  end
62
77
 
63
78
  def exact_envelope(**overrides)
@@ -79,10 +94,9 @@ RSpec.describe Legion::Extensions::Llm::Fleet::ExactOffering do
79
94
  expect(handle_reference_count).to eq(0)
80
95
  end
81
96
 
82
- it 'requires exactly one of registry or provider' do
83
- expect { worker.call(envelope: exact_envelope, registry: inventory::Registry, provider: Object.new) }
84
- .to raise_error(worker::PolicyError)
85
- expect { worker.call(envelope: exact_envelope) }.to raise_error(worker::PolicyError)
97
+ it 'requires the registry — the provider-object topology is deleted (W1)' do
98
+ expect { worker.call(envelope: exact_envelope, registry: nil) }
99
+ .to raise_error(worker::PolicyError, /requires registry/)
86
100
  end
87
101
 
88
102
  it 'releases the lease even when the callable raises' do
@@ -92,7 +106,10 @@ RSpec.describe Legion::Extensions::Llm::Fleet::ExactOffering do
92
106
  end
93
107
 
94
108
  it 'rejects a mismatched offering_id, model, operation, and absent instance before invocation' do
95
- expect { worker.call(envelope: exact_envelope(offering_id: "off:v1:#{'0' * 64}"), registry: inventory::Registry) }
109
+ foreign = Legion::Extensions::Llm::Inventory::Identity.compose_lane_id(
110
+ tier: :local, provider_family: 'vllm', instance_id: 'elsewhere', type: :inference, model: 'gemma4'
111
+ )
112
+ expect { worker.call(envelope: exact_envelope(offering_id: foreign), registry: inventory::Registry) }
96
113
  .to raise_error(errors::ExactOfferingMismatchError)
97
114
  expect { worker.call(envelope: exact_envelope(model: 'other'), registry: inventory::Registry) }
98
115
  .to raise_error(errors::ExactOfferingMismatchError)
@@ -103,19 +120,19 @@ RSpec.describe Legion::Extensions::Llm::Fleet::ExactOffering do
103
120
  expect(recording_callable.calls).to be_empty
104
121
  end
105
122
 
106
- it 'rejects duplicate String/Symbol param spellings' do
123
+ it 'raises ContractError on duplicate String/Symbol param spellings' do
107
124
  expect { worker.call(envelope: exact_envelope(params: { 'messages' => [], :messages => [] }), registry: inventory::Registry) }
108
- .to raise_error(errors::ExactOfferingMismatchError)
125
+ .to raise_error(Legion::Extensions::Llm::Fleet::ContractError, /duplicate param spelling/)
109
126
  end
110
127
 
111
- it 'rejects a params-supplied model (envelope model is the only model)' do
128
+ it 'raises ContractError on a params-supplied model (envelope model is the only model)' do
112
129
  expect { worker.call(envelope: exact_envelope(params: { messages: [], model: 'x' }), registry: inventory::Registry) }
113
- .to raise_error(errors::ExactOfferingMismatchError)
130
+ .to raise_error(Legion::Extensions::Llm::Fleet::ContractError, /must not contain model/)
114
131
  end
115
132
 
116
- it 'rejects a missing required operation param' do
133
+ it 'raises ContractError on a missing required operation param' do
117
134
  expect { worker.call(envelope: exact_envelope(params: {}), registry: inventory::Registry) }
118
- .to raise_error(errors::ExactOfferingMismatchError)
135
+ .to raise_error(Legion::Extensions::Llm::Fleet::ContractError, /requires the messages param/)
119
136
  end
120
137
 
121
138
  it 'passes an explicit empty messages array and forwards unknown kwargs' do
@@ -124,36 +141,32 @@ RSpec.describe Legion::Extensions::Llm::Fleet::ExactOffering do
124
141
  expect(call[:messages]).to eq([])
125
142
  expect(call[:rest]).to eq(temperature: 0.2)
126
143
  end
127
- end
128
144
 
129
- describe 'registry-backed legacy v2 resolution' do
130
- def activate_two_offerings
131
- registry = Legion::Extensions::Llm::Inventory::Registry
132
- registry.reset!
133
- token = registry.claim_instance(instance_key: key, callable: recording_callable, probe_request_handle: probe_coordinator(key))
134
- probe = registry.readiness_probe_started(instance_key: key, publisher_token: token)
135
- two = drafts(native: 'a', model: 'gemma4') + drafts(native: 'b', model: 'gemma4')
136
- registry.activate_instance_snapshot(publisher_token: token, instance_key: key, offerings: two, sequence: 0, probe_token: probe)
137
- end
138
-
139
- it 'refuses zero matches' do
140
- envelope = { provider: 'vllm', provider_instance: 'h200', model: 'nope', operation: 'chat', params: { messages: [] } }
141
- expect { worker.call(envelope: envelope, registry: inventory::Registry) }.to raise_error(errors::ExactOfferingMismatchError)
142
- end
143
-
144
- it 'refuses multiple matches' do
145
- activate_two_offerings
146
- envelope = { provider: 'vllm', provider_instance: 'h200', model: 'gemma4', operation: 'chat', params: { messages: [] } }
147
- expect { worker.call(envelope: envelope, registry: inventory::Registry) }.to raise_error(errors::AmbiguousLegacyOfferingError)
148
- end
149
- end
145
+ it 'rehydrates JSON-round-tripped messages before every message operation' do
146
+ raw_messages = [
147
+ { role: 'system', content: 'follow the rules' },
148
+ {
149
+ role: 'assistant', content: '',
150
+ tool_calls: [{ id: 'call-1', name: 'lookup', arguments: { query: 'status' } }]
151
+ },
152
+ { role: 'tool', tool_call_id: 'call-1', content: 'clean' },
153
+ { role: 'user', content: 'continue' }
154
+ ]
155
+
156
+ %w[chat stream_chat count_tokens].each do |operation|
157
+ wire_envelope = Legion::JSON.load(
158
+ Legion::JSON.dump(exact_envelope(operation: operation, params: { messages: raw_messages }))
159
+ )
160
+ worker.call(envelope: wire_envelope, registry: inventory::Registry)
161
+ end
150
162
 
151
- describe 'legacy provider path still operates' do
152
- it 'dispatches through the provider object without a registry' do
153
- provider = recording_callable
154
- envelope = { operation: 'chat', model: 'gemma4', params: { messages: [] } }
155
- worker.call(envelope: envelope, provider: provider)
156
- expect(recording_callable.calls.map { |c| c[:op] }).to eq([:chat])
163
+ expect(recording_callable.calls.map { |call| call[:op] }).to eq(%i[chat stream_chat count_tokens])
164
+ recording_callable.calls.each do |call|
165
+ expect(call[:messages]).to all(be_a(Legion::Extensions::Llm::Canonical::Message))
166
+ expect(call[:messages].map(&:role)).to eq(%i[system assistant tool user])
167
+ expect(call[:messages][1].tool_calls.first.name).to eq('lookup')
168
+ expect(call[:messages][2].tool_call_id).to eq('call-1')
169
+ end
157
170
  end
158
171
  end
159
172
 
@@ -167,13 +180,14 @@ RSpec.describe Legion::Extensions::Llm::Fleet::ExactOffering do
167
180
  expect { token_validator.validate_exact_execution_claims!(missing, envelope) }
168
181
  .to raise_error(Legion::Extensions::Llm::Fleet::TokenError)
169
182
 
170
- mismatched = { execution_contract: protocol::EXACT_EXECUTION_CONTRACT, offering_id: "off:v1:#{'0' * 64}" }
183
+ mismatched = { execution_contract: protocol::EXACT_EXECUTION_CONTRACT, offering_id: 'local:other:other:inference:other' }
171
184
  expect { token_validator.validate_exact_execution_claims!(mismatched, envelope) }
172
185
  .to raise_error(Legion::Extensions::Llm::Fleet::TokenError)
173
186
  end
174
187
 
175
- it 'is a no-op for a legacy v2 envelope without the marker' do
176
- expect { token_validator.validate_exact_execution_claims!({}, { operation: 'chat' }) }.not_to raise_error
188
+ it 'verifies unconditionally — the marker-absence branch is deleted (S3)' do
189
+ expect { token_validator.validate_exact_execution_claims!({}, { operation: 'chat' }) }
190
+ .to raise_error(Legion::Extensions::Llm::Fleet::TokenError, /missing signed execution_contract/)
177
191
  end
178
192
  end
179
193
  end
@@ -1,56 +1,58 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'spec_helper'
3
4
  require 'legion/extensions/llm/fleet/provider_responder'
5
+ require 'support/ssot_registry_helpers'
4
6
 
5
7
  RSpec.describe Legion::Extensions::Llm::Fleet::ProviderResponder do
8
+ include SsotRegistryHelpers
9
+
6
10
  let(:protocol) { Legion::Extensions::Llm::Fleet::Protocol }
7
- let(:payload) do
11
+ let(:key) { instance_key(family: 'ollama', instance: 'default') }
12
+ let(:callable) do
13
+ Class.new do
14
+ def chat(messages, model:, **_params)
15
+ Legion::Extensions::Llm::Canonical::Response.build(
16
+ text: "chat #{model} #{messages.first.text}",
17
+ model: model,
18
+ stop_reason: :end_turn,
19
+ usage: Legion::Extensions::Llm::Canonical::Usage.build(input_tokens: 1, output_tokens: 2)
20
+ )
21
+ end
22
+ end.new
23
+ end
24
+ # Claim field keeps the name offering_id (D4); value = the lane 5 tuple.
25
+ let(:offering_id) do
26
+ Legion::Extensions::Llm::Inventory::Registry.snapshot.lanes_for(instance_key: key).first.lane_id
27
+ end
28
+ let(:payload) { payload_for(offering_id) }
29
+
30
+ def payload_for(offering_id)
8
31
  {
9
32
  request_id: 'req-1',
10
33
  correlation_id: 'corr-1',
11
34
  idempotency_key: 'idem-1',
12
- operation: :chat,
13
- provider: :ollama,
14
- provider_instance: :default,
35
+ operation: 'chat',
36
+ provider: 'ollama',
37
+ provider_instance: 'default',
15
38
  model: 'llama3',
16
- params: { messages: [{ role: 'user', content: 'hello' }], temperature: 0.1 },
39
+ params: { messages: [{ role: 'user', content: 'hello' }] },
17
40
  reply_to: 'reply.queue',
18
41
  message_context: { conversation_id: 'conv-1' },
19
- caller: { identity: 'user:matt' },
42
+ caller: { identity: 'user:test' },
20
43
  trace_context: { trace_id: 'trace-1' },
21
44
  signed_token: 'unsigned',
22
45
  timeout_seconds: 30,
23
46
  expires_at: (Time.now.utc + 30).iso8601,
24
- protocol_version: protocol::VERSION
47
+ protocol_version: protocol::VERSION,
48
+ execution_contract: protocol::EXACT_EXECUTION_CONTRACT,
49
+ offering_id: offering_id
25
50
  }
26
51
  end
27
- let(:provider_instances) do
28
- {
29
- default: {
30
- base_url: 'http://localhost:11434',
31
- fleet: { respond_to_requests: true }
32
- }
33
- }
34
- end
35
- let(:provider_class) do
36
- Class.new do
37
- attr_reader :settings
38
-
39
- def initialize(settings)
40
- @settings = settings
41
- end
42
-
43
- def chat(messages:, model:, **params)
44
- {
45
- content: "chat #{model} #{messages.first[:content]}",
46
- usage: { input_tokens: 1, output_tokens: 2 },
47
- metadata: { temperature: params[:temperature], base_url: settings[:base_url] }
48
- }
49
- end
50
- end
51
- end
52
52
 
53
53
  before do
54
+ Legion::Extensions::Llm::Inventory::Registry.reset!
55
+ claim_and_activate(key: key, callable: callable, coordinator: probe_coordinator(key), model: 'llama3')
54
56
  Legion::Extensions::Llm::Fleet::WorkerExecution.reset_idempotency_cache!
55
57
  allow(Legion::Extensions::Llm::Fleet::Settings).to receive(:value).and_call_original
56
58
  allow(Legion::Extensions::Llm::Fleet::Settings).to receive(:value)
@@ -59,6 +61,14 @@ RSpec.describe Legion::Extensions::Llm::Fleet::ProviderResponder do
59
61
  .with(:fleet, :responder, :require_idempotency, default: nil).and_return(false)
60
62
  end
61
63
 
64
+ it 'L6: the dead provider_class/provider_instances params are deleted (v3 dispatch never constructs a provider)' do
65
+ params = described_class.instance_method(:call).parameters
66
+ expect(params).not_to include(%i[key provider_class])
67
+ expect(params).not_to include(%i[key provider_instances])
68
+ expect { described_class.call(payload: payload, provider_family: 'ollama', provider_class: Object) }
69
+ .to raise_error(ArgumentError, /provider_class/)
70
+ end
71
+
62
72
  it 'does not require the legion-llm namespace on responder nodes' do
63
73
  hide_const('Legion::LLM') if defined?(Legion::LLM)
64
74
 
@@ -66,47 +76,55 @@ RSpec.describe Legion::Extensions::Llm::Fleet::ProviderResponder do
66
76
  expect(described_class).to respond_to(:call)
67
77
  end
68
78
 
69
- it 'builds a provider from the requested instance, dispatches the operation, and publishes a fleet response' do
79
+ it 'dispatches the exact request through the registry and publishes the serialized Canonical::Response (E3)' do
70
80
  response_message = instance_double(Legion::Extensions::Llm::Transport::Messages::FleetResponse, publish: true)
71
- allow(Legion::Extensions::Llm::Transport::Messages::FleetResponse).to receive(:new).and_return(response_message)
81
+ published_args = nil
82
+ allow(Legion::Extensions::Llm::Transport::Messages::FleetResponse).to receive(:new) do |*args|
83
+ published_args = args
84
+ response_message
85
+ end
72
86
 
73
87
  response = described_class.call(
74
88
  payload: payload,
75
- provider_family: :ollama,
76
- provider_class: provider_class,
77
- provider_instances: provider_instances
89
+ provider_family: 'ollama',
90
+ registry: Legion::Extensions::Llm::Inventory::Registry
78
91
  )
79
92
 
80
- expect(response).to include(content: 'chat llama3 hello')
81
- expect(Legion::Extensions::Llm::Transport::Messages::FleetResponse).to have_received(:new).with(
82
- hash_including(
83
- request_id: 'req-1',
84
- correlation_id: 'corr-1',
85
- provider: :ollama,
86
- provider_instance: :default,
87
- model: 'llama3',
88
- content: 'chat llama3 hello',
89
- usage: { input_tokens: 1, output_tokens: 2 },
90
- metadata: { temperature: 0.1, base_url: 'http://localhost:11434' }
91
- )
93
+ expect(response).to be_a(Legion::Extensions::Llm::Canonical::Response)
94
+ expect(response.text).to eq('chat llama3 hello')
95
+ expect(published_args.first).to include(
96
+ request_id: 'req-1',
97
+ correlation_id: 'corr-1',
98
+ provider: 'ollama',
99
+ provider_instance: 'default',
100
+ model: 'llama3',
101
+ execution_contract: protocol::EXACT_EXECUTION_CONTRACT,
102
+ offering_id: offering_id
92
103
  )
104
+ published = published_args.first[:response]
105
+ expect(published).to include(text: 'chat llama3 hello', stop_reason: :end_turn)
106
+ # The envelope carries the serialized Canonical::Response; the Usage member
107
+ # is the canonical object (JSON encoding at publish serializes it per as_json).
108
+ expect(published[:usage]).to be_a(Legion::Extensions::Llm::Canonical::Usage)
109
+ expect(published[:usage].input_tokens).to eq(1)
110
+ expect(published[:usage].output_tokens).to eq(2)
93
111
  expect(response_message).to have_received(:publish)
94
112
  end
95
113
 
96
- it 'rejects legacy fleet protocol fields before provider execution' do
114
+ it 'rejects legacy fleet protocol fields before execution (P4)' do
97
115
  expect do
98
116
  described_class.call(
99
117
  payload: payload.merge(request_type: 'chat'),
100
- provider_family: :ollama,
101
- provider_class: provider_class,
102
- provider_instances: provider_instances
118
+ provider_family: 'ollama',
119
+ registry: Legion::Extensions::Llm::Inventory::Registry
103
120
  )
104
121
  end.to raise_error(ArgumentError, /request_type/)
105
122
  end
106
123
 
107
124
  it 'reports whether a provider instance is enabled for fleet responses' do
108
- expect(described_class.enabled_for?(provider_instances)).to be(true)
125
+ expect(described_class.enabled_for?(default: { fleet: { respond_to_requests: true } })).to be(true)
109
126
  expect(described_class.enabled_for?(default: { fleet: { respond_to_requests: false } })).to be(false)
127
+ expect(described_class.enabled_for?(-> { { default: { fleet: { respond_to_requests: 'true' } } } })).to be(true)
110
128
  end
111
129
 
112
130
  it 'raises an actionable configuration error when fleet transport messages cannot load' do
@@ -118,62 +136,122 @@ RSpec.describe Legion::Extensions::Llm::Fleet::ProviderResponder do
118
136
  end.to raise_error(described_class::ConfigurationError, /fleet responder transport unavailable/)
119
137
  end
120
138
 
121
- describe 'execution_contract marker validation' do
122
- it 'preserves false values for both envelope key spellings' do
123
- symbol_envelope = described_class::FleetEnvelope.new(data: { execution_contract: false })
124
- string_envelope = described_class::FleetEnvelope.new(data: { 'execution_contract' => false })
139
+ describe 'P2 — exact-only execution contract' do
140
+ it 'rejects a missing marker (legacy v2 tolerance is deleted)' do
141
+ legacy = payload_for(offering_id).except(:execution_contract)
142
+ envelope = described_class.parse_payload(legacy)
143
+ expect { described_class.check_envelope!(envelope, provider_family: 'ollama') }
144
+ .to raise_error(ArgumentError, /execution_contract is required/)
145
+ end
125
146
 
126
- expect(symbol_envelope[:execution_contract]).to be(false)
127
- expect(string_envelope[:execution_contract]).to be(false)
147
+ it 'rejects an unknown nonempty marker' do
148
+ envelope = described_class.parse_payload(payload.merge(execution_contract: 'made_up'))
149
+ expect { described_class.check_envelope!(envelope, provider_family: 'ollama') }
150
+ .to raise_error(Legion::Extensions::Llm::Fleet::ContractError, /execution_contract must be/)
128
151
  end
129
152
 
130
- it 'accepts a legacy v2 envelope with no marker' do
153
+ it 'requires the exact fields for the exact marker' do
154
+ envelope = described_class.parse_payload(payload.merge(offering_id: nil))
155
+ expect { described_class.check_envelope!(envelope, provider_family: 'ollama') }
156
+ .to raise_error(ArgumentError, /offering_id is required/)
157
+ end
158
+
159
+ it 'rejects a false marker before dispatch' do
160
+ envelope = described_class.parse_payload(payload.merge(execution_contract: false))
161
+ expect { described_class.check_envelope!(envelope, provider_family: 'ollama') }
162
+ .to raise_error(Legion::Extensions::Llm::Fleet::ContractError, /execution_contract must be/)
163
+ end
164
+
165
+ it 'accepts a complete exact envelope' do
131
166
  envelope = described_class.parse_payload(payload)
132
- expect { described_class.check_envelope!(envelope, provider_family: :ollama) }.not_to raise_error
167
+ expect { described_class.check_envelope!(envelope, provider_family: 'ollama') }.not_to raise_error
133
168
  end
169
+ end
134
170
 
135
- it 'accepts an explicitly nil marker as legacy v2' do
136
- envelope = described_class.parse_payload(payload.merge(execution_contract: nil))
137
- expect { described_class.check_envelope!(envelope, provider_family: :ollama) }.not_to raise_error
171
+ describe 'P3 — explicit protocol version' do
172
+ it 'rejects a missing protocol_version (no default fill)' do
173
+ envelope = described_class.parse_payload(payload.except(:protocol_version))
174
+ expect { described_class.check_envelope!(envelope, provider_family: 'ollama') }
175
+ .to raise_error(ArgumentError, /protocol_version is required/)
138
176
  end
139
177
 
140
- { symbol: { execution_contract: false }, string: { 'execution_contract' => false } }.each do |spelling, marker|
141
- it "rejects a false #{spelling}-key marker before provider or registry dispatch" do
142
- registry = class_spy(Legion::Extensions::Llm::Inventory::Registry)
143
- allow(provider_class).to receive(:new).and_call_original
144
-
145
- expect do
146
- described_class.call(
147
- payload: payload.merge(marker),
148
- provider_family: :ollama,
149
- provider_class: provider_class,
150
- provider_instances: provider_instances,
151
- registry: registry
152
- )
153
- end.to raise_error(ArgumentError, /unknown execution_contract: false/)
178
+ it 'rejects a mismatched protocol_version' do
179
+ envelope = described_class.parse_payload(payload.merge(protocol_version: 2))
180
+ expect { described_class.check_envelope!(envelope, provider_family: 'ollama') }
181
+ .to raise_error(ArgumentError, /protocol_version must be 3/)
182
+ end
183
+ end
154
184
 
155
- expect(provider_class).not_to have_received(:new)
156
- expect(registry).not_to have_received(:snapshot)
157
- end
185
+ describe 'E1 — wire normalization' do
186
+ it 'raises on a wrong-shape payload (the silent {} fallback is deleted)' do
187
+ expect { described_class.parse_payload(42) }
188
+ .to raise_error(Legion::Extensions::Llm::Fleet::ContractError, /expected Hash or String, got Integer/)
158
189
  end
159
190
 
160
- it 'rejects an unknown nonempty marker' do
161
- envelope = described_class.parse_payload(payload.merge(execution_contract: 'made_up'))
162
- expect { described_class.check_envelope!(envelope, provider_family: :ollama) }
163
- .to raise_error(ArgumentError, /unknown execution_contract/)
191
+ it 'wraps Hash payloads through the single FleetEnvelope entry' do
192
+ envelope = described_class.parse_payload({ 'request_id' => 'r1', protocol_version: 3 })
193
+ expect(envelope).to be_a(Legion::Extensions::Llm::Fleet::FleetEnvelope)
194
+ expect(envelope.request_id).to eq('r1')
164
195
  end
165
196
 
166
- it 'requires offering_id for the exact marker' do
167
- envelope = described_class.parse_payload(payload.merge(execution_contract: 'exact_offering_v1'))
168
- expect { described_class.check_envelope!(envelope, provider_family: :ollama) }
169
- .to raise_error(ArgumentError, /offering_id is required/)
197
+ it 'reads false values for both key spellings' do
198
+ symbol_envelope = Legion::Extensions::Llm::Fleet::FleetEnvelope.new(data: { execution_contract: false })
199
+ string_envelope = Legion::Extensions::Llm::Fleet::FleetEnvelope.new(data: { 'execution_contract' => false })
200
+
201
+ expect(symbol_envelope[:execution_contract]).to be(false)
202
+ expect(string_envelope[:execution_contract]).to be(false)
170
203
  end
204
+ end
171
205
 
172
- it 'accepts a complete exact envelope' do
173
- exact = payload.merge(execution_contract: 'exact_offering_v1', offering_id: 'off:v1:abc')
174
- envelope = described_class.parse_payload(exact)
175
- expect { described_class.check_envelope!(envelope, provider_family: :ollama) }.not_to raise_error
176
- expect(described_class.exact?(envelope)).to be(true)
206
+ describe 'F6 — retryability derived from the ProviderOutcome kind table' do
207
+ it 'never retries contract, policy, auth, or classification errors' do
208
+ expect(described_class.retryable_error?(Legion::Extensions::Llm::Fleet::ContractError.new('x'))).to be(false)
209
+ expect(described_class.retryable_error?(Legion::Extensions::Llm::Fleet::WorkerExecution::PolicyError.new('x'))).to be(false)
210
+ expect(described_class.retryable_error?(Legion::Extensions::Llm::Fleet::TokenError.new('x'))).to be(false)
211
+ expect(described_class.retryable_error?(Legion::Extensions::Llm::Inventory::Errors::ExactOfferingMismatchError.new('x'))).to be(false)
212
+ expect(described_class.retryable_error?(described_class::ConfigurationError.new('x'))).to be(false)
213
+ end
214
+
215
+ it 'retries transient provider kinds only' do
216
+ expect(described_class.retryable_error?(Legion::Extensions::Llm::RateLimitError.new(nil, 'x'))).to be(true)
217
+ expect(described_class.retryable_error?(Legion::Extensions::Llm::OverloadedError.new(nil, 'x'))).to be(true)
218
+ expect(described_class.retryable_error?(Timeout::Error.new('x'))).to be(true)
219
+ expect(described_class.retryable_error?(Legion::Extensions::Llm::UnauthorizedError.new(nil, 'x'))).to be(false)
220
+ end
221
+ end
222
+
223
+ describe 'G5 — thinking never crosses the fleet' do
224
+ it 'excludes thinking from the response envelope exactly once at the builder' do
225
+ thinking_callable = Class.new do
226
+ def chat(_messages, model:, **)
227
+ Legion::Extensions::Llm::Canonical::Response.build(
228
+ text: 'done',
229
+ model: model,
230
+ stop_reason: :end_turn,
231
+ thinking: Legion::Extensions::Llm::Canonical::Thinking.build(content: 'secret-reasoning')
232
+ )
233
+ end
234
+ end.new
235
+ key2 = instance_key(family: 'ollama', instance: 'thinking')
236
+ Legion::Extensions::Llm::Inventory::Registry.reset!
237
+ claim_and_activate(key: key2, callable: thinking_callable, coordinator: probe_coordinator(key2), model: 'llama3')
238
+ thinking_offering = Legion::Extensions::Llm::Inventory::Registry
239
+ .snapshot.lanes_for(instance_key: key2).first.lane_id
240
+
241
+ response_message = instance_double(Legion::Extensions::Llm::Transport::Messages::FleetResponse, publish: true)
242
+ published_args = nil
243
+ allow(Legion::Extensions::Llm::Transport::Messages::FleetResponse).to receive(:new) do |*args|
244
+ published_args = args
245
+ response_message
246
+ end
247
+
248
+ described_class.call(
249
+ payload: payload_for(thinking_offering).merge(provider_instance: 'thinking'),
250
+ provider_family: 'ollama',
251
+ registry: Legion::Extensions::Llm::Inventory::Registry
252
+ )
253
+
254
+ expect(published_args.first[:response]).not_to have_key(:thinking)
177
255
  end
178
256
  end
179
257
  end
@@ -17,10 +17,15 @@ RSpec.describe Legion::Extensions::Llm::Fleet::TokenValidator do
17
17
  reply_to: 'reply.queue',
18
18
  message_context: { conversation_id: 'conv-1' },
19
19
  params: { messages: [{ role: 'user', content: 'hello' }] },
20
- caller: { identity: 'user:matt' },
20
+ caller: { identity: 'user:primary' },
21
21
  trace_context: { trace_id: 'trace-1' },
22
22
  timeout_seconds: 30,
23
- expires_at: expires_at
23
+ expires_at: expires_at,
24
+ # v3: exact claims are verified unconditionally (S3) — the envelope and
25
+ # the signed claims must both carry the exact marker + offering_id.
26
+ # The claim value is the lane's 5 tuple (D4).
27
+ execution_contract: Legion::Extensions::Llm::Fleet::Protocol::EXACT_EXECUTION_CONTRACT,
28
+ offering_id: 'local:ollama:default:inference:llama3'
24
29
  }
25
30
  end
26
31
  let(:claims) do