lex-llm 0.4.18 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) 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 +19 -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 +125 -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 +73 -0
  16. data/lib/legion/extensions/llm/canonical/usage.rb +61 -0
  17. data/lib/legion/extensions/llm/canonical.rb +49 -0
  18. data/lib/legion/extensions/llm/chat.rb +3 -5
  19. data/lib/legion/extensions/llm/connection.rb +5 -1
  20. data/lib/legion/extensions/llm/error.rb +3 -7
  21. data/lib/legion/extensions/llm/fleet/envelope_validation.rb +1 -3
  22. data/lib/legion/extensions/llm/fleet/provider_responder.rb +1 -3
  23. data/lib/legion/extensions/llm/fleet/token_validator.rb +1 -3
  24. data/lib/legion/extensions/llm/model/info.rb +4 -6
  25. data/lib/legion/extensions/llm/models.rb +3 -3
  26. data/lib/legion/extensions/llm/provider/open_ai_compatible.rb +7 -3
  27. data/lib/legion/extensions/llm/routing/lane_key.rb +1 -3
  28. data/lib/legion/extensions/llm/stream_accumulator.rb +1 -1
  29. data/lib/legion/extensions/llm/streaming.rb +1 -3
  30. data/lib/legion/extensions/llm/tool.rb +1 -3
  31. data/lib/legion/extensions/llm/version.rb +1 -1
  32. data/lib/legion/extensions/llm.rb +118 -35
  33. data/spec/fixtures/ruby.mp3 +0 -0
  34. data/spec/fixtures/ruby.mp4 +0 -0
  35. data/spec/fixtures/ruby.png +0 -0
  36. data/spec/fixtures/ruby.txt +1 -0
  37. data/spec/fixtures/ruby.wav +0 -0
  38. data/spec/fixtures/ruby.xml +1 -0
  39. data/spec/fixtures/sample.pdf +0 -0
  40. data/spec/legion/extensions/llm/agent_spec.rb +179 -0
  41. data/spec/legion/extensions/llm/attachment_spec.rb +25 -0
  42. data/spec/legion/extensions/llm/auto_registration_spec.rb +38 -0
  43. data/spec/legion/extensions/llm/canonical/chunk_spec.rb +285 -0
  44. data/spec/legion/extensions/llm/canonical/content_block_spec.rb +179 -0
  45. data/spec/legion/extensions/llm/canonical/message_spec.rb +203 -0
  46. data/spec/legion/extensions/llm/canonical/params_spec.rb +159 -0
  47. data/spec/legion/extensions/llm/canonical/request_spec.rb +174 -0
  48. data/spec/legion/extensions/llm/canonical/response_spec.rb +234 -0
  49. data/spec/legion/extensions/llm/canonical/thinking_spec.rb +151 -0
  50. data/spec/legion/extensions/llm/canonical/tool_call_spec.rb +191 -0
  51. data/spec/legion/extensions/llm/canonical/tool_definition_spec.rb +174 -0
  52. data/spec/legion/extensions/llm/canonical/usage_spec.rb +138 -0
  53. data/spec/legion/extensions/llm/configuration_spec.rb +38 -0
  54. data/spec/legion/extensions/llm/conformance/client_translator_examples.rb +269 -0
  55. data/spec/legion/extensions/llm/conformance/conformance.rb +51 -0
  56. data/spec/legion/extensions/llm/conformance/echo_translator.rb +56 -0
  57. data/spec/legion/extensions/llm/conformance/echo_translator_spec.rb +13 -0
  58. data/spec/legion/extensions/llm/conformance/fixtures/canonical_empty_response.json +13 -0
  59. data/spec/legion/extensions/llm/conformance/fixtures/canonical_error_response.json +19 -0
  60. data/spec/legion/extensions/llm/conformance/fixtures/canonical_fleet_round_trip.json +81 -0
  61. data/spec/legion/extensions/llm/conformance/fixtures/canonical_metering_audit_events.json +101 -0
  62. data/spec/legion/extensions/llm/conformance/fixtures/canonical_params_mapping_request.json +21 -0
  63. data/spec/legion/extensions/llm/conformance/fixtures/canonical_simple_text_request.json +13 -0
  64. data/spec/legion/extensions/llm/conformance/fixtures/canonical_simple_text_response.json +13 -0
  65. data/spec/legion/extensions/llm/conformance/fixtures/canonical_stop_reason_matrix.json +36 -0
  66. data/spec/legion/extensions/llm/conformance/fixtures/canonical_streaming_accumulated_response.json +20 -0
  67. data/spec/legion/extensions/llm/conformance/fixtures/canonical_streaming_error_chunks.json +26 -0
  68. data/spec/legion/extensions/llm/conformance/fixtures/canonical_streaming_text_chunks.json +33 -0
  69. data/spec/legion/extensions/llm/conformance/fixtures/canonical_streaming_thinking_chunks.json +42 -0
  70. data/spec/legion/extensions/llm/conformance/fixtures/canonical_streaming_tool_call_chunks.json +41 -0
  71. data/spec/legion/extensions/llm/conformance/fixtures/canonical_system_prompt_request.json +14 -0
  72. data/spec/legion/extensions/llm/conformance/fixtures/canonical_thinking_request.json +18 -0
  73. data/spec/legion/extensions/llm/conformance/fixtures/canonical_thinking_response.json +17 -0
  74. data/spec/legion/extensions/llm/conformance/fixtures/canonical_tool_results_continuation_request.json +75 -0
  75. data/spec/legion/extensions/llm/conformance/fixtures/canonical_tool_use_response.json +25 -0
  76. data/spec/legion/extensions/llm/conformance/fixtures/canonical_tools_request.json +34 -0
  77. data/spec/legion/extensions/llm/conformance/provider_translator_examples.rb +390 -0
  78. data/spec/legion/extensions/llm/connection_logging_spec.rb +53 -0
  79. data/spec/legion/extensions/llm/connection_retry_spec.rb +36 -0
  80. data/spec/legion/extensions/llm/context_spec.rb +127 -0
  81. data/spec/legion/extensions/llm/credential_sources_spec.rb +468 -0
  82. data/spec/legion/extensions/llm/error_middleware_spec.rb +102 -0
  83. data/spec/legion/extensions/llm/error_spec.rb +87 -0
  84. data/spec/legion/extensions/llm/fleet/provider_responder_spec.rb +120 -0
  85. data/spec/legion/extensions/llm/fleet/token_validator_spec.rb +163 -0
  86. data/spec/legion/extensions/llm/fleet/worker_execution_spec.rb +128 -0
  87. data/spec/legion/extensions/llm/fleet_messages_spec.rb +402 -0
  88. data/spec/legion/extensions/llm/gemspec_spec.rb +25 -0
  89. data/spec/legion/extensions/llm/message_spec.rb +64 -0
  90. data/spec/legion/extensions/llm/model/info_spec.rb +222 -0
  91. data/spec/legion/extensions/llm/models_spec.rb +104 -0
  92. data/spec/legion/extensions/llm/provider/open_ai_compatible_spec.rb +203 -0
  93. data/spec/legion/extensions/llm/provider_contract_spec.rb +60 -0
  94. data/spec/legion/extensions/llm/provider_settings_spec.rb +76 -0
  95. data/spec/legion/extensions/llm/provider_spec.rb +592 -0
  96. data/spec/legion/extensions/llm/registry_event_builder_spec.rb +68 -0
  97. data/spec/legion/extensions/llm/registry_publisher_spec.rb +22 -0
  98. data/spec/legion/extensions/llm/responses/response_objects_spec.rb +75 -0
  99. data/spec/legion/extensions/llm/responses/thinking_extractor_spec.rb +75 -0
  100. data/spec/legion/extensions/llm/routing/model_offering_spec.rb +222 -0
  101. data/spec/legion/extensions/llm/routing/offering_registry_spec.rb +50 -0
  102. data/spec/legion/extensions/llm/routing/registry_event_spec.rb +120 -0
  103. data/spec/legion/extensions/llm/stream_accumulator_spec.rb +103 -0
  104. data/spec/legion/extensions/llm/streaming_spec.rb +108 -0
  105. data/spec/legion/extensions/llm/tool_spec.rb +94 -0
  106. data/spec/legion/extensions/llm/transport/fleet_lane_spec.rb +60 -0
  107. data/spec/legion/extensions/llm/utils_spec.rb +113 -0
  108. data/spec/legion/extensions/llm_base_contract_spec.rb +110 -0
  109. data/spec/legion/extensions/llm_extension_spec.rb +78 -0
  110. data/spec/legion/extensions/llm_root_spec.rb +51 -0
  111. data/spec/spec_helper.rb +24 -0
  112. data/spec/support/fake_llm_provider.rb +148 -0
  113. data/spec/support/llm_configuration.rb +21 -0
  114. data/spec/support/rspec_configuration.rb +19 -0
  115. data/spec/support/simplecov_configuration.rb +20 -0
  116. metadata +96 -15
@@ -0,0 +1,390 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Shared examples for canonical provider translator conformance.
4
+ #
5
+ # Every provider translator must implement:
6
+ # - render_request(canonical_request) => wire Hash
7
+ # - parse_response(wire_hash) => Canonical::Response
8
+ # - parse_chunk(raw_chunk) => Canonical::Chunk | nil
9
+ # - capabilities => Hash
10
+ #
11
+ # Usage:
12
+ # it_behaves_like 'a canonical provider translator', MyTranslatorClass
13
+
14
+ RSpec.shared_examples 'a canonical provider translator' do |translator_class|
15
+ let(:translator) { translator_class.new }
16
+ let(:canonical) { Legion::Extensions::Llm::Canonical }
17
+ let(:conformance) { Canonical::Conformance }
18
+
19
+ describe '#capabilities' do
20
+ it 'returns a Hash' do
21
+ expect(translator.capabilities).to be_a(Hash)
22
+ end
23
+
24
+ it 'includes a :provider key' do
25
+ expect(translator.capabilities).to have_key(:provider)
26
+ expect(translator.capabilities[:provider]).to be_a(String)
27
+ end
28
+ end
29
+
30
+ describe '#render_request' do
31
+ context 'with a simple text request' do
32
+ let(:canonical_req) do
33
+ canonical::Request.from_hash(conformance.fixture_symbolized('canonical_simple_text_request'))
34
+ end
35
+
36
+ it 'renders a non-empty wire payload' do
37
+ wire = translator.render_request(canonical_req)
38
+ expect(wire).to be_a(Hash)
39
+ expect(wire).not_to be_empty
40
+ end
41
+
42
+ it 'includes model or messages' do
43
+ wire = translator.render_request(canonical_req)
44
+ expect(wire.keys & %i[model messages]).not_to be_empty
45
+ end
46
+
47
+ it 'preserves message content' do
48
+ wire = translator.render_request(canonical_req)
49
+ wire_str = wire.to_s
50
+ expect(wire_str).to include('how are you')
51
+ end
52
+ end
53
+
54
+ context 'with a system prompt' do
55
+ let(:canonical_req) do
56
+ canonical::Request.from_hash(conformance.fixture_symbolized('canonical_system_prompt_request'))
57
+ end
58
+
59
+ it 'renders the system prompt in provider-appropriate format' do
60
+ wire = translator.render_request(canonical_req)
61
+ wire_str = wire.to_s.downcase
62
+ expect(wire_str).to match(/helpful|haiku/)
63
+ end
64
+ end
65
+
66
+ context 'with parameter mapping' do
67
+ let(:canonical_req) do
68
+ canonical::Request.from_hash(conformance.fixture_symbolized('canonical_params_mapping_request'))
69
+ end
70
+
71
+ it 'renders params in provider-appropriate format' do
72
+ wire = translator.render_request(canonical_req)
73
+ expect(wire).to be_a(Hash)
74
+ wire_str = wire.to_s
75
+ expect(wire_str).to match(/[0-9]+/)
76
+ end
77
+ end
78
+
79
+ context 'with tools defined' do
80
+ let(:canonical_req) do
81
+ canonical::Request.from_hash(conformance.fixture_symbolized('canonical_tools_request'))
82
+ end
83
+
84
+ it 'renders tools in provider format' do
85
+ wire = translator.render_request(canonical_req)
86
+ wire_str = wire.to_s.downcase
87
+ expect(wire_str).to include('get_weather')
88
+ end
89
+
90
+ it 'includes tool parameters' do
91
+ wire = translator.render_request(canonical_req)
92
+ wire_str = wire.to_s.downcase
93
+ expect(wire_str).to include('location')
94
+ end
95
+ end
96
+
97
+ context 'with tool results continuation (multi-turn)' do
98
+ let(:canonical_req) do
99
+ canonical::Request.from_hash(
100
+ conformance.fixture_symbolized('canonical_tool_results_continuation_request')
101
+ )
102
+ end
103
+
104
+ it 'renders the full conversation history' do
105
+ wire = translator.render_request(canonical_req)
106
+ wire_str = wire.to_s.downcase
107
+ expect(wire_str).to include('weather')
108
+ end
109
+
110
+ it 'renders mixed client and registry tool calls' do
111
+ wire = translator.render_request(canonical_req)
112
+ wire_str = wire.to_s.downcase
113
+ expect(wire_str).to include('get_weather')
114
+ expect(wire_str).to include('summarize')
115
+ end
116
+ end
117
+
118
+ context 'with thinking enabled' do
119
+ let(:canonical_req) do
120
+ canonical::Request.from_hash(conformance.fixture_symbolized('canonical_thinking_request'))
121
+ end
122
+
123
+ it 'renders thinking configuration' do
124
+ wire = translator.render_request(canonical_req)
125
+ wire_str = wire.to_s.downcase
126
+ expect(wire_str).to match(/think|reason|budget|effort/)
127
+ end
128
+ end
129
+
130
+ context 'with streaming request' do
131
+ let(:canonical_req) do
132
+ canonical::Request.from_hash(
133
+ conformance.fixture_symbolized('canonical_simple_text_request').merge({ 'stream' => true })
134
+ )
135
+ end
136
+
137
+ it 'renders with streaming indicator' do
138
+ wire = translator.render_request(canonical_req)
139
+ wire_str = wire.to_s.downcase
140
+ expect(wire_str).to include('stream')
141
+ end
142
+ end
143
+ end
144
+
145
+ # parse_response: tests translator.parse_response(wire_hash) => Canonical::Response
146
+ # For self-test (echo translator), wire == canonical-form (symbolized).
147
+ # Real provider translators convert provider-specific wire format to canonical.
148
+ describe '#parse_response' do
149
+ context 'with a simple text response' do
150
+ let(:wire_response) { conformance.fixture_symbolized('canonical_simple_text_response') }
151
+
152
+ it 'returns a Canonical::Response' do
153
+ response = translator.parse_response(wire_response)
154
+ expect(response).to be_a(canonical::Response)
155
+ end
156
+
157
+ it 'preserves text content' do
158
+ response = translator.parse_response(wire_response)
159
+ expect(response.text).to eq("I'm doing well, thank you for asking!")
160
+ end
161
+
162
+ it 'sets stop_reason' do
163
+ response = translator.parse_response(wire_response)
164
+ expect(response.stop_reason).to eq(:end_turn)
165
+ end
166
+
167
+ it 'includes usage data' do
168
+ response = translator.parse_response(wire_response)
169
+ expect(response.usage).to be_a(canonical::Usage)
170
+ expect(response.usage.input_tokens).to eq(12)
171
+ expect(response.usage.output_tokens).to eq(10)
172
+ end
173
+ end
174
+
175
+ context 'with a tool use response' do
176
+ let(:wire_response) { conformance.fixture_symbolized('canonical_tool_use_response') }
177
+
178
+ it 'parses tool calls correctly' do
179
+ response = translator.parse_response(wire_response)
180
+ expect(response).to be_a(canonical::Response)
181
+ expect(response.tool_call?).to be true
182
+ expect(response.tool_calls).to be_an(Array)
183
+ expect(response.tool_calls.first).to be_a(canonical::ToolCall)
184
+ expect(response.stop_reason).to eq(:tool_use)
185
+ end
186
+
187
+ it 'preserves tool call arguments as a Hash' do
188
+ response = translator.parse_response(wire_response)
189
+ args = response.tool_calls.first.arguments
190
+ expect(args).to be_a(Hash)
191
+ expect(args[:location]).to eq('San Francisco, CA')
192
+ end
193
+
194
+ it 'has no text when response is tool-only' do
195
+ response = translator.parse_response(wire_response)
196
+ expect(response.text).to eq('')
197
+ end
198
+ end
199
+
200
+ context 'with thinking response' do
201
+ let(:wire_response) { conformance.fixture_symbolized('canonical_thinking_response') }
202
+
203
+ it 'parses thinking content and signature' do
204
+ response = translator.parse_response(wire_response)
205
+ expect(response.thinking).to be_a(canonical::Thinking)
206
+ expect(response.thinking.content).to include('quantum')
207
+ expect(response.thinking.signature).to be_a(String)
208
+ end
209
+
210
+ it 'preserves thinking tokens in usage' do
211
+ response = translator.parse_response(wire_response)
212
+ expect(response.usage).to be_a(canonical::Usage)
213
+ expect(response.usage.thinking_tokens).to eq(120)
214
+ end
215
+ end
216
+
217
+ context 'with error response' do
218
+ let(:wire_response) { conformance.fixture_symbolized('canonical_error_response') }
219
+
220
+ it 'parses error responses without crashing' do
221
+ response = translator.parse_response(wire_response)
222
+ expect(response).to be_a(canonical::Response)
223
+ expect(response.error?).to be true
224
+ expect(response.stop_reason).to eq(:error)
225
+ end
226
+
227
+ it 'preserves error metadata' do
228
+ response = translator.parse_response(wire_response)
229
+ expect(response.metadata).to have_key(:error)
230
+ end
231
+ end
232
+
233
+ context 'with empty response' do
234
+ let(:wire_response) { conformance.fixture_symbolized('canonical_empty_response') }
235
+
236
+ it 'handles empty responses gracefully' do
237
+ response = translator.parse_response(wire_response)
238
+ expect(response).to be_a(canonical::Response)
239
+ expect(response.text).to eq('')
240
+ expect(response.tool_calls).to eq([])
241
+ end
242
+ end
243
+ end
244
+
245
+ describe '#parse_chunk' do
246
+ context 'with text delta chunks' do
247
+ let(:stream_fixture) { conformance.fixture('canonical_streaming_text_chunks') }
248
+ let(:chunks) { stream_fixture['chunks'] }
249
+
250
+ it 'parses text delta chunks' do
251
+ text_chunk = chunks.find { |c| c['type'] == 'text_delta' }
252
+ parsed = translator.parse_chunk(text_chunk)
253
+ expect(parsed).to be_a(canonical::Chunk)
254
+ expect(parsed.type).to eq(:text_delta)
255
+ expect(parsed.delta).to be_a(String)
256
+ end
257
+
258
+ it 'parses the done chunk' do
259
+ done_chunk = chunks.find { |c| c['type'] == 'done' }
260
+ parsed = translator.parse_chunk(done_chunk)
261
+ expect(parsed).to be_a(canonical::Chunk)
262
+ expect(parsed.type).to eq(:done)
263
+ expect(parsed.stop_reason).to eq(:end_turn)
264
+ end
265
+ end
266
+
267
+ context 'with thinking delta chunks' do
268
+ let(:stream_fixture) { conformance.fixture('canonical_streaming_thinking_chunks') }
269
+ let(:chunks) { stream_fixture['chunks'] }
270
+
271
+ it 'parses thinking delta chunks' do
272
+ thinking_chunk = chunks.find { |c| c['type'] == 'thinking_delta' }
273
+ parsed = translator.parse_chunk(thinking_chunk)
274
+ expect(parsed).to be_a(canonical::Chunk)
275
+ expect(parsed.type).to eq(:thinking_delta)
276
+ end
277
+
278
+ it 'preserves signature on thinking deltas' do
279
+ sig_chunk = chunks.find { |c| c['type'] == 'thinking_delta' && !c['signature'].nil? }
280
+ next if sig_chunk.nil?
281
+
282
+ parsed = translator.parse_chunk(sig_chunk)
283
+ expect(parsed.signature).to be_a(String)
284
+ end
285
+ end
286
+
287
+ context 'with tool call delta chunks' do
288
+ let(:stream_fixture) { conformance.fixture('canonical_streaming_tool_call_chunks') }
289
+ let(:chunks) { stream_fixture['chunks'] }
290
+
291
+ it 'parses tool call delta chunks' do
292
+ tool_chunk = chunks.find { |c| c['type'] == 'tool_call_delta' }
293
+ parsed = translator.parse_chunk(tool_chunk)
294
+ expect(parsed).to be_a(canonical::Chunk)
295
+ expect(parsed.type).to eq(:tool_call_delta)
296
+ end
297
+
298
+ it 'preserves tool call identity across chunks' do
299
+ tool_chunks = chunks.select { |c| c['type'] == 'tool_call_delta' }
300
+ parsed_chunks = tool_chunks.map { |c| translator.parse_chunk(c) }
301
+ ids = parsed_chunks.map { |c| c.tool_call&.id }
302
+ expect(ids.uniq.length).to eq(1)
303
+ end
304
+ end
305
+
306
+ context 'with error chunk' do
307
+ let(:stream_fixture) { conformance.fixture('canonical_streaming_error_chunks') }
308
+ let(:chunks) { stream_fixture['chunks'] }
309
+
310
+ it 'parses error chunks' do
311
+ error_chunk = chunks.find { |c| c['type'] == 'error' }
312
+ parsed = translator.parse_chunk(error_chunk)
313
+ expect(parsed).to be_a(canonical::Chunk)
314
+ expect(parsed.type).to eq(:error)
315
+ expect(parsed.error?).to be true
316
+ end
317
+ end
318
+ end
319
+
320
+ describe 'stop_reason mapping' do
321
+ let(:matrix) { conformance.fixture_symbolized('canonical_stop_reason_matrix') }
322
+
323
+ it 'maps all canonical stop reasons' do
324
+ canonical::Response::STOP_REASONS.each do |reason|
325
+ resp = canonical::Response.build(stop_reason: reason, text: 'test')
326
+ expect(resp.stop_reason).to eq(reason)
327
+ end
328
+ end
329
+
330
+ it 'rejects invalid stop reasons' do
331
+ expect { canonical::Response.build(stop_reason: :invalid_reason, text: 'test') }
332
+ .to raise_error(ArgumentError, /Invalid stop_reason/)
333
+ end
334
+ end
335
+
336
+ describe 'round-trip consistency' do
337
+ it 'accumulated chunks equal non-streaming response for text' do
338
+ stream_fixture = conformance.fixture('canonical_streaming_text_chunks')
339
+ chunks = stream_fixture['chunks']
340
+
341
+ accumulated_text = ''
342
+ final_stop_reason = nil
343
+
344
+ chunks.each do |raw_chunk|
345
+ chunk = translator.parse_chunk(raw_chunk)
346
+ next unless chunk
347
+
348
+ case chunk.type
349
+ when :text_delta
350
+ accumulated_text += chunk.delta
351
+ when :done
352
+ final_stop_reason = chunk.stop_reason
353
+ end
354
+ end
355
+
356
+ expect(accumulated_text).to eq('Hello, world! How can I help you today?')
357
+ expect(final_stop_reason).to eq(:end_turn)
358
+ end
359
+
360
+ it 'accumulated chunks equal non-streaming response for thinking + text' do
361
+ stream_fixture = conformance.fixture('canonical_streaming_thinking_chunks')
362
+ chunks = stream_fixture['chunks']
363
+
364
+ accumulated_thinking = ''
365
+ accumulated_text = ''
366
+ final_stop_reason = nil
367
+ final_signature = nil
368
+
369
+ chunks.each do |raw_chunk|
370
+ chunk = translator.parse_chunk(raw_chunk)
371
+ next unless chunk
372
+
373
+ case chunk.type
374
+ when :thinking_delta
375
+ accumulated_thinking += chunk.delta
376
+ final_signature = chunk.signature if chunk.signature
377
+ when :text_delta
378
+ accumulated_text += chunk.delta
379
+ when :done
380
+ final_stop_reason = chunk.stop_reason
381
+ end
382
+ end
383
+
384
+ expect(accumulated_thinking).not_to be_empty
385
+ expect(accumulated_text).not_to be_empty
386
+ expect(final_signature).to be_a(String)
387
+ expect(final_stop_reason).to eq(:end_turn)
388
+ end
389
+ end
390
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe Legion::Extensions::Llm::Connection do
6
+ describe 'logging middleware configuration' do
7
+ let(:provider) do
8
+ instance_double(
9
+ Legion::Extensions::Llm::Provider,
10
+ api_base: 'https://example.com',
11
+ configured?: true,
12
+ headers: {}
13
+ )
14
+ end
15
+
16
+ let(:config) do
17
+ instance_double(
18
+ Legion::Extensions::Llm::Configuration,
19
+ request_timeout: 300,
20
+ max_retries: 3,
21
+ retry_interval: 0.1,
22
+ retry_interval_randomness: 0.5,
23
+ retry_backoff_factor: 2,
24
+ http_proxy: nil,
25
+ log_regexp_timeout: 1.0
26
+ )
27
+ end
28
+
29
+ it 'disables body logging when log level is above DEBUG' do
30
+ logger = Logger.new(File::NULL, level: Logger::INFO)
31
+ allow(config).to receive(:logger).and_return(logger)
32
+
33
+ connection = described_class.new(provider, config).connection
34
+ handler = connection.builder.handlers.find { |h| h.klass == Faraday::Response::Logger }
35
+ middleware = handler.build(->(_env) { Faraday::Response.new })
36
+ options = middleware.instance_variable_get(:@formatter).instance_variable_get(:@options)
37
+
38
+ expect(options[:bodies]).to be(false)
39
+ end
40
+
41
+ it 'enables body logging when log level is DEBUG' do
42
+ logger = Logger.new(File::NULL, level: Logger::DEBUG)
43
+ allow(config).to receive(:logger).and_return(logger)
44
+
45
+ connection = described_class.new(provider, config).connection
46
+ handler = connection.builder.handlers.find { |h| h.klass == Faraday::Response::Logger }
47
+ middleware = handler.build(->(_env) { Faraday::Response.new })
48
+ options = middleware.instance_variable_get(:@formatter).instance_variable_get(:@options)
49
+
50
+ expect(options[:bodies]).to be(true)
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe Legion::Extensions::Llm::Connection do
6
+ describe 'retry middleware configuration' do
7
+ let(:provider) do
8
+ instance_double(
9
+ Legion::Extensions::Llm::Provider,
10
+ api_base: 'https://example.com',
11
+ configured?: true,
12
+ headers: {}
13
+ )
14
+ end
15
+
16
+ let(:config) do
17
+ instance_double(
18
+ Legion::Extensions::Llm::Configuration,
19
+ request_timeout: 300,
20
+ max_retries: 3,
21
+ retry_interval: 0.1,
22
+ retry_interval_randomness: 0.5,
23
+ retry_backoff_factor: 2,
24
+ http_proxy: nil
25
+ )
26
+ end
27
+
28
+ it 'retries POST requests for transient failures' do
29
+ connection = described_class.new(provider, config).connection
30
+ retry_handler = connection.builder.handlers.find { |handler| handler.klass == Faraday::Retry::Middleware }
31
+ retry_options = retry_handler.instance_variable_get(:@args).first
32
+
33
+ expect(retry_options[:methods]).to include(:post)
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe Legion::Extensions::Llm::Context do
6
+ include_context 'with configured Legion::Extensions::Llm'
7
+ include_context 'with fake llm provider'
8
+
9
+ describe '#initialize' do
10
+ it 'creates a copy of the global configuration' do
11
+ # Get current config values
12
+ original_model = Legion::Extensions::Llm.config.default_model
13
+ original_log_regexp_timeout = Legion::Extensions::Llm.config.log_regexp_timeout
14
+
15
+ # Create context with modified config
16
+ context = Legion::Extensions::Llm.context do |config|
17
+ config.default_model = 'modified-model'
18
+ config.fake_llm_api_key = 'modified-key'
19
+ config.log_regexp_timeout = 5.0
20
+ end
21
+
22
+ # Verify global config is unchanged
23
+ expect(Legion::Extensions::Llm.config.default_model).to eq(original_model)
24
+ expect(Legion::Extensions::Llm.config.log_regexp_timeout).to eq(original_log_regexp_timeout)
25
+
26
+ # Verify context has modified config
27
+ expect(context.config.default_model).to eq('modified-model')
28
+ expect(context.config.fake_llm_api_key).to eq('modified-key')
29
+ expect(context.config.log_regexp_timeout).to eq(5.0)
30
+ end
31
+
32
+ it 'preserves log_regexp_timeout when Regexp timeout is unavailable' do
33
+ allow(Regexp).to receive(:respond_to?).and_call_original
34
+ allow(Regexp).to receive(:respond_to?).with(:timeout).and_return(false)
35
+ allow(Legion::Extensions::Llm.logger).to receive(:warn)
36
+
37
+ context = Legion::Extensions::Llm.context do |config|
38
+ config.log_regexp_timeout = 5.0
39
+ end
40
+
41
+ expect(context.config.log_regexp_timeout).to eq(5.0)
42
+ end
43
+ end
44
+
45
+ describe 'context chat operations' do
46
+ it 'creates a chat with context-specific configuration' do
47
+ context = Legion::Extensions::Llm.context do |config|
48
+ config.default_model = 'fake-chat-model'
49
+ end
50
+
51
+ chat = context.chat(provider: :fake_llm, assume_model_exists: true)
52
+ expect(chat.model.id).to eq('fake-chat-model')
53
+ end
54
+
55
+ it 'allows specifying a model when creating the chat' do
56
+ context = Legion::Extensions::Llm.context do |config|
57
+ config.default_model = 'fake-chat-model'
58
+ end
59
+
60
+ chat = context.chat(model: 'other-fake-chat-model', provider: :fake_llm, assume_model_exists: true)
61
+ expect(chat.model.id).to eq('other-fake-chat-model')
62
+ end
63
+ end
64
+
65
+ describe 'context embed operations' do
66
+ it 'respects context-specific embedding model' do
67
+ context = Legion::Extensions::Llm.context do |config|
68
+ config.default_embedding_model = 'fake-embed'
69
+ end
70
+
71
+ embedding = context.embed('Test embedding', provider: :fake_llm, assume_model_exists: true)
72
+ expect(embedding.model).to eq('fake-embed')
73
+ end
74
+
75
+ it 'allows specifying a model at embed time' do
76
+ context = Legion::Extensions::Llm.context do |config|
77
+ config.default_embedding_model = 'fake-embed'
78
+ end
79
+
80
+ embedding = context.embed('Test embedding', model: 'override-embed', provider: :fake_llm,
81
+ assume_model_exists: true)
82
+ expect(embedding.model).to eq('override-embed')
83
+ end
84
+ end
85
+
86
+ describe 'multiple independent contexts' do
87
+ it 'allows multiple contexts with different configurations' do
88
+ context1 = Legion::Extensions::Llm.context do |config|
89
+ config.default_model = 'fake-chat-1'
90
+ config.log_regexp_timeout = 5.0
91
+ end
92
+
93
+ context2 = Legion::Extensions::Llm.context do |config|
94
+ config.default_model = 'fake-chat-2'
95
+ end
96
+
97
+ chat1 = context1.chat(provider: :fake_llm, assume_model_exists: true)
98
+ chat2 = context2.chat(provider: :fake_llm, assume_model_exists: true)
99
+
100
+ expect(chat1.model.id).to eq('fake-chat-1')
101
+ expect(context1.config.log_regexp_timeout).to eq(5.0)
102
+
103
+ expect(chat2.model.id).to eq('fake-chat-2')
104
+ expected_timeout = Regexp.respond_to?(:timeout) ? (Regexp.timeout || 1.0) : nil
105
+ expect(context2.config.log_regexp_timeout).to eq(expected_timeout)
106
+ end
107
+
108
+ it 'ensures changes in one context do not affect another' do
109
+ context1 = Legion::Extensions::Llm.context do |config|
110
+ config.fake_llm_api_key = 'key1'
111
+ config.default_model = 'model1'
112
+ end
113
+
114
+ context2 = Legion::Extensions::Llm.context do |config|
115
+ config.fake_llm_api_key = 'key2'
116
+ config.default_model = 'model2'
117
+ end
118
+
119
+ # Modify context1 after creation
120
+ context1.config.fake_llm_api_key = 'modified-key1'
121
+
122
+ # Context2 should be unaffected
123
+ expect(context2.config.fake_llm_api_key).to eq('key2')
124
+ expect(context2.config.default_model).to eq('model2')
125
+ end
126
+ end
127
+ end