lex-llm 0.4.18 → 0.5.1

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 (125) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +13 -2
  3. data/B1b-conformance-kit.md +79 -0
  4. data/CHANGELOG.md +27 -0
  5. data/lex-llm.gemspec +2 -3
  6. data/lib/legion/extensions/llm/attachment.rb +1 -1
  7. data/lib/legion/extensions/llm/canonical/chunk.rb +184 -0
  8. data/lib/legion/extensions/llm/canonical/content_block.rb +126 -0
  9. data/lib/legion/extensions/llm/canonical/message.rb +138 -0
  10. data/lib/legion/extensions/llm/canonical/params.rb +61 -0
  11. data/lib/legion/extensions/llm/canonical/request.rb +117 -0
  12. data/lib/legion/extensions/llm/canonical/response.rb +124 -0
  13. data/lib/legion/extensions/llm/canonical/thinking.rb +81 -0
  14. data/lib/legion/extensions/llm/canonical/tool_call.rb +134 -0
  15. data/lib/legion/extensions/llm/canonical/tool_definition.rb +98 -0
  16. data/lib/legion/extensions/llm/canonical/tool_schema.rb +46 -0
  17. data/lib/legion/extensions/llm/canonical/usage.rb +74 -0
  18. data/lib/legion/extensions/llm/canonical.rb +50 -0
  19. data/lib/legion/extensions/llm/chat.rb +3 -5
  20. data/lib/legion/extensions/llm/connection.rb +5 -1
  21. data/lib/legion/extensions/llm/error.rb +5 -7
  22. data/lib/legion/extensions/llm/fleet/envelope_validation.rb +1 -3
  23. data/lib/legion/extensions/llm/fleet/provider_responder.rb +1 -3
  24. data/lib/legion/extensions/llm/fleet/token_validator.rb +1 -3
  25. data/lib/legion/extensions/llm/model/info.rb +4 -6
  26. data/lib/legion/extensions/llm/models.rb +3 -3
  27. data/lib/legion/extensions/llm/provider/open_ai_compatible.rb +9 -4
  28. data/lib/legion/extensions/llm/provider.rb +21 -4
  29. data/lib/legion/extensions/llm/provider_contract.rb +10 -1
  30. data/lib/legion/extensions/llm/routing/lane_key.rb +1 -3
  31. data/lib/legion/extensions/llm/stream_accumulator.rb +40 -1
  32. data/lib/legion/extensions/llm/streaming.rb +13 -5
  33. data/lib/legion/extensions/llm/tool.rb +1 -3
  34. data/lib/legion/extensions/llm/version.rb +1 -1
  35. data/lib/legion/extensions/llm.rb +118 -35
  36. data/spec/fixtures/ruby.mp3 +0 -0
  37. data/spec/fixtures/ruby.mp4 +0 -0
  38. data/spec/fixtures/ruby.png +0 -0
  39. data/spec/fixtures/ruby.txt +1 -0
  40. data/spec/fixtures/ruby.wav +0 -0
  41. data/spec/fixtures/ruby.xml +1 -0
  42. data/spec/fixtures/sample.pdf +0 -0
  43. data/spec/legion/extensions/llm/agent_spec.rb +179 -0
  44. data/spec/legion/extensions/llm/attachment_spec.rb +25 -0
  45. data/spec/legion/extensions/llm/auto_registration_spec.rb +38 -0
  46. data/spec/legion/extensions/llm/canonical/chunk_spec.rb +285 -0
  47. data/spec/legion/extensions/llm/canonical/content_block_spec.rb +179 -0
  48. data/spec/legion/extensions/llm/canonical/message_spec.rb +203 -0
  49. data/spec/legion/extensions/llm/canonical/params_spec.rb +159 -0
  50. data/spec/legion/extensions/llm/canonical/request_spec.rb +174 -0
  51. data/spec/legion/extensions/llm/canonical/response_spec.rb +234 -0
  52. data/spec/legion/extensions/llm/canonical/thinking_spec.rb +151 -0
  53. data/spec/legion/extensions/llm/canonical/tool_call_spec.rb +191 -0
  54. data/spec/legion/extensions/llm/canonical/tool_definition_spec.rb +221 -0
  55. data/spec/legion/extensions/llm/canonical/tool_schema_spec.rb +83 -0
  56. data/spec/legion/extensions/llm/canonical/usage_spec.rb +178 -0
  57. data/spec/legion/extensions/llm/configuration_spec.rb +38 -0
  58. data/spec/legion/extensions/llm/conformance/client_translator_examples.rb +432 -0
  59. data/spec/legion/extensions/llm/conformance/conformance.rb +51 -0
  60. data/spec/legion/extensions/llm/conformance/echo_translator.rb +56 -0
  61. data/spec/legion/extensions/llm/conformance/echo_translator_spec.rb +13 -0
  62. data/spec/legion/extensions/llm/conformance/fixtures/canonical_empty_response.json +13 -0
  63. data/spec/legion/extensions/llm/conformance/fixtures/canonical_error_response.json +19 -0
  64. data/spec/legion/extensions/llm/conformance/fixtures/canonical_fleet_round_trip.json +81 -0
  65. data/spec/legion/extensions/llm/conformance/fixtures/canonical_metering_audit_events.json +101 -0
  66. data/spec/legion/extensions/llm/conformance/fixtures/canonical_params_mapping_request.json +21 -0
  67. data/spec/legion/extensions/llm/conformance/fixtures/canonical_server_tool_continuation_request.json +43 -0
  68. data/spec/legion/extensions/llm/conformance/fixtures/canonical_server_tool_use_response.json +29 -0
  69. data/spec/legion/extensions/llm/conformance/fixtures/canonical_simple_text_request.json +13 -0
  70. data/spec/legion/extensions/llm/conformance/fixtures/canonical_simple_text_response.json +13 -0
  71. data/spec/legion/extensions/llm/conformance/fixtures/canonical_stop_reason_matrix.json +36 -0
  72. data/spec/legion/extensions/llm/conformance/fixtures/canonical_streaming_accumulated_response.json +20 -0
  73. data/spec/legion/extensions/llm/conformance/fixtures/canonical_streaming_error_chunks.json +26 -0
  74. data/spec/legion/extensions/llm/conformance/fixtures/canonical_streaming_server_tool_chunks.json +52 -0
  75. data/spec/legion/extensions/llm/conformance/fixtures/canonical_streaming_text_chunks.json +33 -0
  76. data/spec/legion/extensions/llm/conformance/fixtures/canonical_streaming_thinking_chunks.json +42 -0
  77. data/spec/legion/extensions/llm/conformance/fixtures/canonical_streaming_tool_call_chunks.json +41 -0
  78. data/spec/legion/extensions/llm/conformance/fixtures/canonical_system_prompt_request.json +14 -0
  79. data/spec/legion/extensions/llm/conformance/fixtures/canonical_thinking_request.json +18 -0
  80. data/spec/legion/extensions/llm/conformance/fixtures/canonical_thinking_response.json +17 -0
  81. data/spec/legion/extensions/llm/conformance/fixtures/canonical_tool_results_continuation_request.json +75 -0
  82. data/spec/legion/extensions/llm/conformance/fixtures/canonical_tool_use_response.json +25 -0
  83. data/spec/legion/extensions/llm/conformance/fixtures/canonical_tools_request.json +34 -0
  84. data/spec/legion/extensions/llm/conformance/provider_tool_rendering_examples.rb +77 -0
  85. data/spec/legion/extensions/llm/conformance/provider_translator_examples.rb +390 -0
  86. data/spec/legion/extensions/llm/connection_logging_spec.rb +53 -0
  87. data/spec/legion/extensions/llm/connection_retry_spec.rb +36 -0
  88. data/spec/legion/extensions/llm/context_spec.rb +127 -0
  89. data/spec/legion/extensions/llm/credential_sources_spec.rb +468 -0
  90. data/spec/legion/extensions/llm/error_middleware_spec.rb +102 -0
  91. data/spec/legion/extensions/llm/error_spec.rb +87 -0
  92. data/spec/legion/extensions/llm/fleet/provider_responder_spec.rb +120 -0
  93. data/spec/legion/extensions/llm/fleet/token_validator_spec.rb +163 -0
  94. data/spec/legion/extensions/llm/fleet/worker_execution_spec.rb +128 -0
  95. data/spec/legion/extensions/llm/fleet_messages_spec.rb +402 -0
  96. data/spec/legion/extensions/llm/gemspec_spec.rb +25 -0
  97. data/spec/legion/extensions/llm/message_spec.rb +64 -0
  98. data/spec/legion/extensions/llm/model/info_spec.rb +222 -0
  99. data/spec/legion/extensions/llm/models_spec.rb +104 -0
  100. data/spec/legion/extensions/llm/provider/open_ai_compatible_spec.rb +203 -0
  101. data/spec/legion/extensions/llm/provider/open_ai_compatible_tool_calls_array_spec.rb +68 -0
  102. data/spec/legion/extensions/llm/provider_contract_spec.rb +60 -0
  103. data/spec/legion/extensions/llm/provider_settings_spec.rb +76 -0
  104. data/spec/legion/extensions/llm/provider_spec.rb +613 -0
  105. data/spec/legion/extensions/llm/registry_event_builder_spec.rb +68 -0
  106. data/spec/legion/extensions/llm/registry_publisher_spec.rb +22 -0
  107. data/spec/legion/extensions/llm/responses/response_objects_spec.rb +75 -0
  108. data/spec/legion/extensions/llm/responses/thinking_extractor_spec.rb +75 -0
  109. data/spec/legion/extensions/llm/routing/model_offering_spec.rb +222 -0
  110. data/spec/legion/extensions/llm/routing/offering_registry_spec.rb +50 -0
  111. data/spec/legion/extensions/llm/routing/registry_event_spec.rb +120 -0
  112. data/spec/legion/extensions/llm/stream_accumulator_spec.rb +155 -0
  113. data/spec/legion/extensions/llm/streaming_spec.rb +108 -0
  114. data/spec/legion/extensions/llm/tool_spec.rb +94 -0
  115. data/spec/legion/extensions/llm/transport/fleet_lane_spec.rb +60 -0
  116. data/spec/legion/extensions/llm/utils_spec.rb +113 -0
  117. data/spec/legion/extensions/llm_base_contract_spec.rb +110 -0
  118. data/spec/legion/extensions/llm_extension_spec.rb +78 -0
  119. data/spec/legion/extensions/llm_root_spec.rb +51 -0
  120. data/spec/spec_helper.rb +24 -0
  121. data/spec/support/fake_llm_provider.rb +148 -0
  122. data/spec/support/llm_configuration.rb +21 -0
  123. data/spec/support/rspec_configuration.rb +19 -0
  124. data/spec/support/simplecov_configuration.rb +20 -0
  125. metadata +103 -15
@@ -0,0 +1,402 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ # rubocop:disable RSpec/DescribeClass
6
+ RSpec.describe 'LLM fleet message envelopes' do
7
+ let(:protocol) { Legion::Extensions::Llm::Fleet::Protocol }
8
+ let(:channel_class) do
9
+ Class.new do
10
+ attr_accessor :default_exchange
11
+
12
+ def on_return; end
13
+ def confirm_select(**_kwargs); end
14
+ def wait_for_confirms; end
15
+ end
16
+ end
17
+
18
+ def valid_request_options
19
+ {
20
+ routing_key: 'llm.fleet.inference.qwen.ctx8192',
21
+ request_id: 'req-1',
22
+ correlation_id: 'corr-1',
23
+ operation: :chat,
24
+ provider: :vllm,
25
+ provider_instance: :apollo,
26
+ model: 'qwen',
27
+ params: { messages: [{ role: 'user', content: 'hello' }] },
28
+ reply_to: 'llm.fleet.reply.node',
29
+ message_context: { conversation_id: 'conv-1' },
30
+ caller: { service: 'legion-llm' },
31
+ trace_context: { trace_id: 'trace-1' },
32
+ signed_token: 'signed.jwt',
33
+ timeout_seconds: 30,
34
+ expires_at: '2026-05-06T12:00:30Z',
35
+ protocol_version: protocol::VERSION,
36
+ idempotency_key: 'idem-1'
37
+ }
38
+ end
39
+
40
+ describe Legion::Extensions::Llm::Fleet::Protocol do
41
+ it 'defines fleet protocol v2 message types' do
42
+ expect(described_class::VERSION).to eq(2)
43
+ expect(described_class::REQUEST_TYPE).to eq('llm.fleet.request')
44
+ expect(described_class::RESPONSE_TYPE).to eq('llm.fleet.response')
45
+ expect(described_class::ERROR_TYPE).to eq('llm.fleet.error')
46
+ end
47
+ end
48
+
49
+ describe Legion::Extensions::Llm::Transport::Messages::FleetRequest do
50
+ it 'builds a strict protocol v2 request envelope' do
51
+ message = described_class.new(**valid_request_options)
52
+
53
+ expect(message.type).to eq(protocol::REQUEST_TYPE)
54
+ expect(message.routing_key).to eq('llm.fleet.inference.qwen.ctx8192')
55
+ expect(message.reply_to).to eq('llm.fleet.reply.node')
56
+ expect(message.correlation_id).to eq('corr-1')
57
+ expect(message.message).to include(
58
+ protocol_version: 2,
59
+ request_id: 'req-1',
60
+ correlation_id: 'corr-1',
61
+ operation: :chat,
62
+ provider: :vllm,
63
+ provider_instance: :apollo,
64
+ model: 'qwen',
65
+ params: { messages: [{ role: 'user', content: 'hello' }] },
66
+ reply_to: 'llm.fleet.reply.node',
67
+ message_context: { conversation_id: 'conv-1' },
68
+ caller: { service: 'legion-llm' },
69
+ trace_context: { trace_id: 'trace-1' },
70
+ signed_token: 'signed.jwt',
71
+ timeout_seconds: 30,
72
+ expires_at: '2026-05-06T12:00:30Z',
73
+ idempotency_key: 'idem-1'
74
+ )
75
+ expect(message.message).not_to include(:schema_version, :request_type, :fleet_correlation_id)
76
+ end
77
+
78
+ it 'requires every protocol v2 request field' do
79
+ required = %i[
80
+ request_id correlation_id operation provider provider_instance model params reply_to
81
+ message_context caller trace_context signed_token timeout_seconds expires_at protocol_version idempotency_key
82
+ ]
83
+
84
+ required.each do |field|
85
+ expect do
86
+ described_class.new(**valid_request_options.except(field))
87
+ end.to raise_error(ArgumentError, /#{field}/)
88
+ end
89
+ end
90
+
91
+ it 'rejects protocol versions other than v2' do
92
+ expect do
93
+ described_class.new(**valid_request_options, protocol_version: 1)
94
+ end.to raise_error(ArgumentError, /protocol_version/)
95
+ end
96
+
97
+ it 'rejects legacy fleet envelope fields' do
98
+ %i[schema_version request_type fleet_correlation_id].each do |field|
99
+ expect do
100
+ described_class.new(**valid_request_options, field => 'legacy')
101
+ end.to raise_error(ArgumentError, /#{field}/)
102
+ expect do
103
+ described_class.new(**valid_request_options, field.to_s => 'legacy')
104
+ end.to raise_error(ArgumentError, /#{field}/)
105
+ end
106
+ end
107
+
108
+ it 'publishes requests with mandatory routing, publisher confirms, no spool, and an accepted result by default' do
109
+ channel = instance_double(channel_class, on_return: nil, confirm_select: nil, wait_for_confirms: true)
110
+ exchange = instance_double(
111
+ Legion::Extensions::Llm::Transport::Exchanges::Fleet,
112
+ publish: true,
113
+ name: 'llm.fleet',
114
+ channel: channel
115
+ )
116
+ message = described_class.new(**valid_request_options)
117
+
118
+ allow(Legion::Extensions::Llm::Transport::Exchanges::Fleet).to receive(:cached_instance).and_return(exchange)
119
+ result = message.publish
120
+
121
+ expect(exchange).to have_received(:publish).with(
122
+ kind_of(String),
123
+ hash_including(
124
+ routing_key: 'llm.fleet.inference.qwen.ctx8192',
125
+ mandatory: true,
126
+ correlation_id: 'corr-1',
127
+ type: protocol::REQUEST_TYPE
128
+ )
129
+ )
130
+ expect(channel).to have_received(:on_return)
131
+ expect(channel).to have_received(:confirm_select)
132
+ expect(channel).to have_received(:wait_for_confirms)
133
+ expect(result).to include(status: :accepted, accepted: true, exchange: 'llm.fleet')
134
+ end
135
+
136
+ it 'passes confirm_timeout: to confirm_select and calls wait_for_confirms with no args for timeout option' do
137
+ channel = instance_double(channel_class, on_return: nil, wait_for_confirms: true)
138
+ exchange = instance_double(
139
+ Legion::Extensions::Llm::Transport::Exchanges::Fleet,
140
+ publish: true,
141
+ name: 'llm.fleet',
142
+ channel: channel
143
+ )
144
+ allow(channel).to receive(:confirm_select)
145
+ message = described_class.new(**valid_request_options)
146
+
147
+ allow(Legion::Extensions::Llm::Transport::Exchanges::Fleet).to receive(:cached_instance).and_return(exchange)
148
+ result = message.publish(publish_confirm_timeout_ms: 5000)
149
+
150
+ expect(channel).to have_received(:confirm_select).with(confirm_timeout: 5000)
151
+ expect(channel).to have_received(:wait_for_confirms).with(no_args)
152
+ expect(result).to include(status: :accepted, accepted: true)
153
+ end
154
+ end
155
+
156
+ describe Legion::Extensions::Llm::Transport::Messages::FleetResponse do
157
+ it 'builds a protocol v2 correlated response envelope' do
158
+ message = described_class.new(
159
+ request_id: 'req-1',
160
+ correlation_id: 'corr-1',
161
+ idempotency_key: 'idem-1',
162
+ operation: :chat,
163
+ reply_to: 'llm.fleet.reply.node',
164
+ provider: :vllm,
165
+ provider_instance: :apollo,
166
+ model: 'qwen',
167
+ content: 'hello',
168
+ message_context: { conversation_id: 'conv-1' },
169
+ trace_context: { trace_id: 'trace-1' }
170
+ )
171
+
172
+ expect(message.type).to eq(protocol::RESPONSE_TYPE)
173
+ expect(message.routing_key).to eq('llm.fleet.reply.node')
174
+ expect(message.correlation_id).to eq('corr-1')
175
+ expect(message.message).to include(
176
+ protocol_version: 2,
177
+ request_id: 'req-1',
178
+ correlation_id: 'corr-1',
179
+ idempotency_key: 'idem-1',
180
+ operation: :chat,
181
+ reply_to: 'llm.fleet.reply.node',
182
+ provider: :vllm,
183
+ provider_instance: :apollo,
184
+ model: 'qwen',
185
+ content: 'hello',
186
+ message_context: { conversation_id: 'conv-1' },
187
+ trace_context: { trace_id: 'trace-1' }
188
+ )
189
+ expect(message.message).not_to include(:schema_version)
190
+ end
191
+
192
+ it 'rejects response protocol versions other than v2' do
193
+ expect do
194
+ described_class.new(
195
+ request_id: 'req-1',
196
+ correlation_id: 'corr-1',
197
+ reply_to: 'llm.fleet.reply.node',
198
+ content: 'hello',
199
+ protocol_version: 1
200
+ )
201
+ end.to raise_error(ArgumentError, /protocol_version/)
202
+ end
203
+
204
+ it 'does not expose thinking in caller-visible response payloads' do
205
+ message = described_class.new(
206
+ request_id: 'req-1',
207
+ correlation_id: 'corr-1',
208
+ reply_to: 'llm.fleet.reply.node',
209
+ content: 'visible',
210
+ thinking: 'hidden'
211
+ )
212
+
213
+ expect(message.message).to include(content: 'visible')
214
+ expect(message.message).not_to have_key(:thinking)
215
+ expect(message.encode_message).not_to include('hidden', 'thinking')
216
+ end
217
+
218
+ it 'publishes replies through the AMQP default exchange without confirms or mandatory routing' do
219
+ default_exchange = instance_double(Bunny::Exchange, publish: true)
220
+ channel = instance_double(Bunny::Channel, default_exchange: default_exchange)
221
+ message = described_class.new(
222
+ request_id: 'req-1',
223
+ correlation_id: 'corr-1',
224
+ reply_to: 'llm.fleet.reply.node',
225
+ content: 'hello'
226
+ )
227
+
228
+ allow(message).to receive(:channel).and_return(channel)
229
+ message.publish
230
+
231
+ expect(default_exchange).to have_received(:publish).with(
232
+ kind_of(String),
233
+ hash_including(
234
+ routing_key: 'llm.fleet.reply.node',
235
+ mandatory: false,
236
+ correlation_id: 'corr-1',
237
+ type: protocol::RESPONSE_TYPE
238
+ )
239
+ )
240
+ end
241
+
242
+ it 'accepts standard transport publish options and returns accepted reply results' do
243
+ channel = instance_double(channel_class, default_exchange: nil, on_return: nil)
244
+ default_exchange = instance_double(Bunny::Exchange, publish: true, name: 'default', channel: channel)
245
+ allow(channel).to receive(:default_exchange).and_return(default_exchange)
246
+ message = described_class.new(
247
+ request_id: 'req-1',
248
+ correlation_id: 'corr-1',
249
+ reply_to: 'llm.fleet.reply.node',
250
+ content: 'hello'
251
+ )
252
+
253
+ allow(message).to receive(:channel).and_return(channel)
254
+ result = message.publish(return_result: true, headers: { 'x-custom' => 'yes' }, mandatory: true)
255
+
256
+ expect(default_exchange).to have_received(:publish).with(
257
+ kind_of(String),
258
+ hash_including(
259
+ routing_key: 'llm.fleet.reply.node',
260
+ mandatory: true,
261
+ headers: hash_including('x-custom' => 'yes')
262
+ )
263
+ )
264
+ expect(result).to include(
265
+ status: :accepted,
266
+ accepted: true,
267
+ exchange: 'default',
268
+ routing_key: 'llm.fleet.reply.node',
269
+ correlation_id: 'corr-1'
270
+ )
271
+ end
272
+
273
+ it 'returns a transport-style failure result for transient reply publish errors' do
274
+ default_exchange = instance_double(Bunny::Exchange, name: 'default')
275
+ channel = instance_double(Bunny::Channel, default_exchange: default_exchange)
276
+ message = described_class.new(
277
+ request_id: 'req-1',
278
+ correlation_id: 'corr-1',
279
+ reply_to: 'llm.fleet.reply.node',
280
+ content: 'hello'
281
+ )
282
+
283
+ allow(default_exchange).to receive(:publish).and_raise(IOError, 'socket closed')
284
+ allow(message).to receive(:channel).and_return(channel)
285
+ allow(message).to receive(:handle_exception)
286
+ result = message.publish(spool: false)
287
+
288
+ expect(result).to include(
289
+ status: :failed,
290
+ accepted: false,
291
+ error_class: 'IOError',
292
+ routing_key: 'llm.fleet.reply.node',
293
+ correlation_id: 'corr-1'
294
+ )
295
+ end
296
+ end
297
+
298
+ describe Legion::Extensions::Llm::Transport::Messages::FleetError do
299
+ it 'builds a protocol v2 correlated error envelope' do
300
+ message = described_class.new(
301
+ request_id: 'req-1',
302
+ correlation_id: 'corr-1',
303
+ idempotency_key: 'idem-1',
304
+ operation: :chat,
305
+ reply_to: 'llm.fleet.reply.node',
306
+ provider: :vllm,
307
+ provider_instance: :apollo,
308
+ model: 'qwen',
309
+ code: 'provider_failed',
310
+ message: 'provider failed',
311
+ retryable: true,
312
+ message_context: { conversation_id: 'conv-1' },
313
+ trace_context: { trace_id: 'trace-1' }
314
+ )
315
+
316
+ expect(message.type).to eq(protocol::ERROR_TYPE)
317
+ expect(message.routing_key).to eq('llm.fleet.reply.node')
318
+ expect(message.message).to include(
319
+ protocol_version: 2,
320
+ request_id: 'req-1',
321
+ correlation_id: 'corr-1',
322
+ idempotency_key: 'idem-1',
323
+ operation: :chat,
324
+ reply_to: 'llm.fleet.reply.node',
325
+ provider: :vllm,
326
+ provider_instance: :apollo,
327
+ model: 'qwen',
328
+ code: 'provider_failed',
329
+ message: 'provider failed',
330
+ retryable: true,
331
+ message_context: { conversation_id: 'conv-1' },
332
+ trace_context: { trace_id: 'trace-1' }
333
+ )
334
+ expect(message.message).not_to include(:schema_version)
335
+ end
336
+
337
+ it 'rejects error protocol versions other than v2' do
338
+ expect do
339
+ described_class.new(
340
+ request_id: 'req-1',
341
+ correlation_id: 'corr-1',
342
+ reply_to: 'llm.fleet.reply.node',
343
+ code: 'provider_failed',
344
+ message: 'provider failed',
345
+ protocol_version: 1
346
+ )
347
+ end.to raise_error(ArgumentError, /protocol_version/)
348
+ end
349
+
350
+ it 'publishes errors through the AMQP default exchange without confirms or mandatory routing' do
351
+ default_exchange = instance_double(Bunny::Exchange, publish: true)
352
+ channel = instance_double(Bunny::Channel, default_exchange: default_exchange)
353
+ message = described_class.new(
354
+ request_id: 'req-1',
355
+ correlation_id: 'corr-1',
356
+ reply_to: 'llm.fleet.reply.node',
357
+ code: 'provider_failed',
358
+ message: 'provider failed'
359
+ )
360
+
361
+ allow(message).to receive(:channel).and_return(channel)
362
+ message.publish
363
+
364
+ expect(default_exchange).to have_received(:publish).with(
365
+ kind_of(String),
366
+ hash_including(
367
+ routing_key: 'llm.fleet.reply.node',
368
+ mandatory: false,
369
+ correlation_id: 'corr-1',
370
+ type: protocol::ERROR_TYPE
371
+ )
372
+ )
373
+ end
374
+
375
+ it 'accepts standard transport publish options when publishing errors' do
376
+ channel = instance_double(channel_class, default_exchange: nil, on_return: nil)
377
+ default_exchange = instance_double(Bunny::Exchange, publish: true, name: 'default', channel: channel)
378
+ allow(channel).to receive(:default_exchange).and_return(default_exchange)
379
+ message = described_class.new(
380
+ request_id: 'req-1',
381
+ correlation_id: 'corr-1',
382
+ reply_to: 'llm.fleet.reply.node',
383
+ code: 'provider_failed',
384
+ message: 'provider failed'
385
+ )
386
+
387
+ allow(message).to receive(:channel).and_return(channel)
388
+ result = message.publish(return_result: true, headers: { 'x-custom' => 'yes' }, mandatory: true)
389
+
390
+ expect(default_exchange).to have_received(:publish).with(
391
+ kind_of(String),
392
+ hash_including(
393
+ routing_key: 'llm.fleet.reply.node',
394
+ mandatory: true,
395
+ headers: hash_including('x-custom' => 'yes')
396
+ )
397
+ )
398
+ expect(result).to include(status: :accepted, accepted: true, exchange: 'default')
399
+ end
400
+ end
401
+ end
402
+ # rubocop:enable RSpec/DescribeClass
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ # rubocop:disable RSpec/DescribeClass
6
+ RSpec.describe 'lex-llm.gemspec' do
7
+ subject(:gemspec) { Gem::Specification.load(File.expand_path('../../../../lex-llm.gemspec', __dir__)) }
8
+
9
+ def runtime_dependency(name)
10
+ gemspec.dependencies.find { |dependency| dependency.type == :runtime && dependency.name == name }
11
+ end
12
+
13
+ it 'keeps faraday compatible with Ruby < 4.0' do
14
+ expect(runtime_dependency('faraday').requirement.to_s).to eq('>= 1.10.0')
15
+ end
16
+
17
+ it 'keeps faraday-retry compatible with Faraday v1 and v2' do
18
+ expect(runtime_dependency('faraday-retry').requirement.to_s).to eq('>= 1')
19
+ end
20
+
21
+ it 'publishes as lex-llm' do
22
+ expect(gemspec.name).to eq('lex-llm')
23
+ end
24
+ end
25
+ # rubocop:enable RSpec/DescribeClass
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe Legion::Extensions::Llm::Message do
6
+ describe '#content' do
7
+ it 'normalizes nil content to empty string for assistant tool-call messages' do
8
+ tool_call = Legion::Extensions::Llm::ToolCall.new(id: 'call_1', name: 'weather', arguments: {})
9
+ message = described_class.new(role: :assistant, content: nil, tool_calls: { 'call_1' => tool_call })
10
+
11
+ expect(message.content).to eq('')
12
+ end
13
+
14
+ it 'keeps nil content for messages without tool calls' do
15
+ message = described_class.new(role: :assistant, content: nil, tool_calls: nil)
16
+
17
+ expect(message.content).to be_nil
18
+ end
19
+ end
20
+
21
+ describe '#to_h' do
22
+ it 'does not expose hidden thinking text in public message serialization' do
23
+ message = described_class.new(
24
+ role: :assistant,
25
+ content: 'visible',
26
+ thinking: Legion::Extensions::Llm::Thinking.build(text: 'hidden', signature: 'sig-1'),
27
+ raw: { reasoning_content: 'raw hidden' }
28
+ )
29
+
30
+ expect(message.to_h).to eq(role: :assistant, content: 'visible')
31
+ expect(Legion::JSON.dump(message.to_h)).not_to include('hidden', 'reasoning_content', 'sig-1')
32
+ end
33
+
34
+ it 'keeps hidden thinking text available through internal message serialization' do
35
+ message = described_class.new(
36
+ role: :assistant,
37
+ content: 'visible',
38
+ thinking: Legion::Extensions::Llm::Thinking.build(text: 'hidden', signature: 'sig-1'),
39
+ raw: { reasoning_content: 'raw hidden' }
40
+ )
41
+
42
+ expect(message.to_internal_h).to include(
43
+ role: :assistant,
44
+ content: 'visible',
45
+ thinking: 'hidden',
46
+ thinking_signature: 'sig-1',
47
+ raw: { reasoning_content: 'raw hidden' }
48
+ )
49
+ end
50
+ end
51
+
52
+ describe Legion::Extensions::Llm::Chunk do
53
+ it 'does not expose hidden thinking text in public chunk serialization' do
54
+ chunk = described_class.new(
55
+ role: :assistant,
56
+ content: 'visible',
57
+ thinking: Legion::Extensions::Llm::Thinking.build(text: 'hidden')
58
+ )
59
+
60
+ expect(chunk.to_h).to eq(role: :assistant, content: 'visible')
61
+ expect(chunk.to_internal_h).to include(thinking: 'hidden')
62
+ end
63
+ end
64
+ end