lex-llm 0.4.16 → 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 (117) 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 +33 -0
  5. data/README.md +349 -153
  6. data/lex-llm.gemspec +3 -3
  7. data/lib/legion/extensions/llm/attachment.rb +1 -1
  8. data/lib/legion/extensions/llm/canonical/chunk.rb +184 -0
  9. data/lib/legion/extensions/llm/canonical/content_block.rb +126 -0
  10. data/lib/legion/extensions/llm/canonical/message.rb +125 -0
  11. data/lib/legion/extensions/llm/canonical/params.rb +61 -0
  12. data/lib/legion/extensions/llm/canonical/request.rb +117 -0
  13. data/lib/legion/extensions/llm/canonical/response.rb +124 -0
  14. data/lib/legion/extensions/llm/canonical/thinking.rb +81 -0
  15. data/lib/legion/extensions/llm/canonical/tool_call.rb +134 -0
  16. data/lib/legion/extensions/llm/canonical/tool_definition.rb +73 -0
  17. data/lib/legion/extensions/llm/canonical/usage.rb +61 -0
  18. data/lib/legion/extensions/llm/canonical.rb +49 -0
  19. data/lib/legion/extensions/llm/chat.rb +3 -5
  20. data/lib/legion/extensions/llm/connection.rb +14 -2
  21. data/lib/legion/extensions/llm/error.rb +3 -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 +12 -4
  28. data/lib/legion/extensions/llm/routing/lane_key.rb +1 -3
  29. data/lib/legion/extensions/llm/stream_accumulator.rb +1 -1
  30. data/lib/legion/extensions/llm/streaming.rb +6 -4
  31. data/lib/legion/extensions/llm/tool.rb +1 -3
  32. data/lib/legion/extensions/llm/version.rb +1 -1
  33. data/lib/legion/extensions/llm.rb +118 -35
  34. data/spec/fixtures/ruby.mp3 +0 -0
  35. data/spec/fixtures/ruby.mp4 +0 -0
  36. data/spec/fixtures/ruby.png +0 -0
  37. data/spec/fixtures/ruby.txt +1 -0
  38. data/spec/fixtures/ruby.wav +0 -0
  39. data/spec/fixtures/ruby.xml +1 -0
  40. data/spec/fixtures/sample.pdf +0 -0
  41. data/spec/legion/extensions/llm/agent_spec.rb +179 -0
  42. data/spec/legion/extensions/llm/attachment_spec.rb +25 -0
  43. data/spec/legion/extensions/llm/auto_registration_spec.rb +38 -0
  44. data/spec/legion/extensions/llm/canonical/chunk_spec.rb +285 -0
  45. data/spec/legion/extensions/llm/canonical/content_block_spec.rb +179 -0
  46. data/spec/legion/extensions/llm/canonical/message_spec.rb +203 -0
  47. data/spec/legion/extensions/llm/canonical/params_spec.rb +159 -0
  48. data/spec/legion/extensions/llm/canonical/request_spec.rb +174 -0
  49. data/spec/legion/extensions/llm/canonical/response_spec.rb +234 -0
  50. data/spec/legion/extensions/llm/canonical/thinking_spec.rb +151 -0
  51. data/spec/legion/extensions/llm/canonical/tool_call_spec.rb +191 -0
  52. data/spec/legion/extensions/llm/canonical/tool_definition_spec.rb +174 -0
  53. data/spec/legion/extensions/llm/canonical/usage_spec.rb +138 -0
  54. data/spec/legion/extensions/llm/configuration_spec.rb +38 -0
  55. data/spec/legion/extensions/llm/conformance/client_translator_examples.rb +269 -0
  56. data/spec/legion/extensions/llm/conformance/conformance.rb +51 -0
  57. data/spec/legion/extensions/llm/conformance/echo_translator.rb +56 -0
  58. data/spec/legion/extensions/llm/conformance/echo_translator_spec.rb +13 -0
  59. data/spec/legion/extensions/llm/conformance/fixtures/canonical_empty_response.json +13 -0
  60. data/spec/legion/extensions/llm/conformance/fixtures/canonical_error_response.json +19 -0
  61. data/spec/legion/extensions/llm/conformance/fixtures/canonical_fleet_round_trip.json +81 -0
  62. data/spec/legion/extensions/llm/conformance/fixtures/canonical_metering_audit_events.json +101 -0
  63. data/spec/legion/extensions/llm/conformance/fixtures/canonical_params_mapping_request.json +21 -0
  64. data/spec/legion/extensions/llm/conformance/fixtures/canonical_simple_text_request.json +13 -0
  65. data/spec/legion/extensions/llm/conformance/fixtures/canonical_simple_text_response.json +13 -0
  66. data/spec/legion/extensions/llm/conformance/fixtures/canonical_stop_reason_matrix.json +36 -0
  67. data/spec/legion/extensions/llm/conformance/fixtures/canonical_streaming_accumulated_response.json +20 -0
  68. data/spec/legion/extensions/llm/conformance/fixtures/canonical_streaming_error_chunks.json +26 -0
  69. data/spec/legion/extensions/llm/conformance/fixtures/canonical_streaming_text_chunks.json +33 -0
  70. data/spec/legion/extensions/llm/conformance/fixtures/canonical_streaming_thinking_chunks.json +42 -0
  71. data/spec/legion/extensions/llm/conformance/fixtures/canonical_streaming_tool_call_chunks.json +41 -0
  72. data/spec/legion/extensions/llm/conformance/fixtures/canonical_system_prompt_request.json +14 -0
  73. data/spec/legion/extensions/llm/conformance/fixtures/canonical_thinking_request.json +18 -0
  74. data/spec/legion/extensions/llm/conformance/fixtures/canonical_thinking_response.json +17 -0
  75. data/spec/legion/extensions/llm/conformance/fixtures/canonical_tool_results_continuation_request.json +75 -0
  76. data/spec/legion/extensions/llm/conformance/fixtures/canonical_tool_use_response.json +25 -0
  77. data/spec/legion/extensions/llm/conformance/fixtures/canonical_tools_request.json +34 -0
  78. data/spec/legion/extensions/llm/conformance/provider_translator_examples.rb +390 -0
  79. data/spec/legion/extensions/llm/connection_logging_spec.rb +53 -0
  80. data/spec/legion/extensions/llm/connection_retry_spec.rb +36 -0
  81. data/spec/legion/extensions/llm/context_spec.rb +127 -0
  82. data/spec/legion/extensions/llm/credential_sources_spec.rb +468 -0
  83. data/spec/legion/extensions/llm/error_middleware_spec.rb +102 -0
  84. data/spec/legion/extensions/llm/error_spec.rb +87 -0
  85. data/spec/legion/extensions/llm/fleet/provider_responder_spec.rb +120 -0
  86. data/spec/legion/extensions/llm/fleet/token_validator_spec.rb +163 -0
  87. data/spec/legion/extensions/llm/fleet/worker_execution_spec.rb +128 -0
  88. data/spec/legion/extensions/llm/fleet_messages_spec.rb +402 -0
  89. data/spec/legion/extensions/llm/gemspec_spec.rb +25 -0
  90. data/spec/legion/extensions/llm/message_spec.rb +64 -0
  91. data/spec/legion/extensions/llm/model/info_spec.rb +222 -0
  92. data/spec/legion/extensions/llm/models_spec.rb +104 -0
  93. data/spec/legion/extensions/llm/provider/open_ai_compatible_spec.rb +203 -0
  94. data/spec/legion/extensions/llm/provider_contract_spec.rb +60 -0
  95. data/spec/legion/extensions/llm/provider_settings_spec.rb +76 -0
  96. data/spec/legion/extensions/llm/provider_spec.rb +592 -0
  97. data/spec/legion/extensions/llm/registry_event_builder_spec.rb +68 -0
  98. data/spec/legion/extensions/llm/registry_publisher_spec.rb +22 -0
  99. data/spec/legion/extensions/llm/responses/response_objects_spec.rb +75 -0
  100. data/spec/legion/extensions/llm/responses/thinking_extractor_spec.rb +75 -0
  101. data/spec/legion/extensions/llm/routing/model_offering_spec.rb +222 -0
  102. data/spec/legion/extensions/llm/routing/offering_registry_spec.rb +50 -0
  103. data/spec/legion/extensions/llm/routing/registry_event_spec.rb +120 -0
  104. data/spec/legion/extensions/llm/stream_accumulator_spec.rb +103 -0
  105. data/spec/legion/extensions/llm/streaming_spec.rb +108 -0
  106. data/spec/legion/extensions/llm/tool_spec.rb +94 -0
  107. data/spec/legion/extensions/llm/transport/fleet_lane_spec.rb +60 -0
  108. data/spec/legion/extensions/llm/utils_spec.rb +113 -0
  109. data/spec/legion/extensions/llm_base_contract_spec.rb +110 -0
  110. data/spec/legion/extensions/llm_extension_spec.rb +78 -0
  111. data/spec/legion/extensions/llm_root_spec.rb +51 -0
  112. data/spec/spec_helper.rb +24 -0
  113. data/spec/support/fake_llm_provider.rb +148 -0
  114. data/spec/support/llm_configuration.rb +21 -0
  115. data/spec/support/rspec_configuration.rb +19 -0
  116. data/spec/support/simplecov_configuration.rb +20 -0
  117. metadata +110 -15
@@ -0,0 +1,592 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe Legion::Extensions::Llm::Provider do
6
+ describe 'Hash config support' do
7
+ let(:provider_class) do
8
+ Class.new(described_class) do
9
+ def api_base = 'https://test.invalid'
10
+ end
11
+ end
12
+
13
+ it 'accepts a plain Hash and wraps it so method-style access works' do
14
+ provider = provider_class.new({ request_timeout: 60, max_retries: 2,
15
+ retry_interval: 0, retry_backoff_factor: 0,
16
+ retry_interval_randomness: 0,
17
+ anthropic_api_key: 'sk-test-123' })
18
+ expect(provider.config.anthropic_api_key).to eq('sk-test-123')
19
+ expect(provider.config.request_timeout).to eq(60)
20
+ end
21
+
22
+ it 'converts string keys to symbols' do
23
+ provider = provider_class.new({ 'request_timeout' => 120, 'max_retries' => 1,
24
+ 'retry_interval' => 0, 'retry_backoff_factor' => 0,
25
+ 'retry_interval_randomness' => 0,
26
+ 'some_key' => 'value' })
27
+ expect(provider.config.some_key).to eq('value')
28
+ expect(provider.config.request_timeout).to eq(120)
29
+ end
30
+
31
+ it 'returns nil for missing keys instead of raising' do
32
+ provider = provider_class.new({ request_timeout: 30, max_retries: 0,
33
+ retry_interval: 0, retry_backoff_factor: 0,
34
+ retry_interval_randomness: 0 })
35
+ expect(provider.config.nonexistent_key).to be_nil
36
+ end
37
+
38
+ it 'supports respond_to_missing? for present keys' do
39
+ provider = provider_class.new({ request_timeout: 30, max_retries: 0,
40
+ retry_interval: 0, retry_backoff_factor: 0,
41
+ retry_interval_randomness: 0,
42
+ ollama_api_base: 'http://localhost:11434' })
43
+ expect(provider.config.respond_to?(:ollama_api_base)).to be true
44
+ expect(provider.config.respond_to?(:nonexistent_key)).to be false
45
+ end
46
+
47
+ it 'supports setter methods' do
48
+ provider = provider_class.new({ request_timeout: 30, max_retries: 0,
49
+ retry_interval: 0, retry_backoff_factor: 0,
50
+ retry_interval_randomness: 0 })
51
+ provider.config.new_value = 'hello'
52
+ expect(provider.config.new_value).to eq('hello')
53
+ end
54
+
55
+ it 'still works with a Configuration object' do
56
+ provider = provider_class.new(Legion::Extensions::Llm.config)
57
+ expect(provider.config).to be_a(Legion::Extensions::Llm::Configuration)
58
+ expect(provider.config.request_timeout).to be_a(Numeric)
59
+ end
60
+ end
61
+
62
+ describe '#readiness' do
63
+ it 'returns non-live routing readiness metadata without calling provider endpoints' do
64
+ provider_class = Class.new(described_class) do
65
+ def api_base = 'https://provider.invalid'
66
+ def completion_url = '/v1/chat/completions'
67
+ def models_url = '/v1/models'
68
+ def health_url = '/health'
69
+ end
70
+ provider = provider_class.new(Legion::Extensions::Llm.config)
71
+
72
+ expect(provider.readiness).to include(
73
+ provider: provider.slug.to_sym,
74
+ configured: true,
75
+ ready: true,
76
+ api_base: 'https://provider.invalid',
77
+ endpoints: { completion: '/v1/chat/completions', models: '/v1/models', health: '/health' },
78
+ health: { checked: false }
79
+ )
80
+ end
81
+ end
82
+
83
+ describe '#parse_error' do
84
+ let(:provider_class) do
85
+ Class.new(described_class) do
86
+ def api_base = 'https://provider.invalid'
87
+ end
88
+ end
89
+
90
+ let(:provider) { provider_class.new(Legion::Extensions::Llm.config) }
91
+
92
+ it 'extracts provider message text from incomplete JSON error bodies' do
93
+ response = Struct.new(:status, :body).new(
94
+ 500,
95
+ '{"error":{"message":"The model rejected chat_template_kwargs'
96
+ )
97
+
98
+ expect(provider.parse_error(response)).to eq('The model rejected chat_template_kwargs')
99
+ end
100
+ end
101
+
102
+ describe 'canonical provider contract' do
103
+ let(:model) do
104
+ Legion::Extensions::Llm::Model::Info.new(
105
+ id: 'test-model',
106
+ provider: :contract,
107
+ instance: :primary,
108
+ capabilities: %i[completion streaming tools],
109
+ context_length: 8192,
110
+ metadata: { max_output_tokens: 2048 }
111
+ )
112
+ end
113
+
114
+ let(:provider_class) do
115
+ model_info = model
116
+ Class.new(described_class) do
117
+ def self.name = 'Provider'
118
+
119
+ define_method(:api_base) { 'https://contract.invalid' }
120
+ define_method(:models_url) { '/v1/models' }
121
+ attr_reader :list_model_calls
122
+
123
+ define_method(:list_models) do |live: false, **filters|
124
+ @list_model_calls ||= []
125
+ @list_model_calls << { live: live, filters: filters }
126
+ [model_info]
127
+ end
128
+
129
+ def render_payload(_messages, **)
130
+ {}
131
+ end
132
+
133
+ def parse_completion_response(_response)
134
+ Legion::Extensions::Llm::Message.new(role: :assistant, content: 'ok')
135
+ end
136
+ end
137
+ end
138
+
139
+ let(:provider) do
140
+ provider_class.new({ request_timeout: 30, max_retries: 0,
141
+ retry_interval: 0, retry_backoff_factor: 0,
142
+ retry_interval_randomness: 0,
143
+ instance_id: :primary })
144
+ end
145
+
146
+ it 'exposes a canonical chat alias over complete' do
147
+ allow(provider).to receive(:complete).and_return('ok')
148
+
149
+ expect(provider.chat(messages: [], model: model)).to eq('ok')
150
+ expect(provider).to have_received(:complete).with(
151
+ [], tools: [], temperature: nil, model: model, params: {}, headers: {},
152
+ schema: nil, thinking: nil, tool_prefs: nil
153
+ )
154
+ end
155
+
156
+ it 'exposes a canonical stream_chat alias over complete' do
157
+ seen = []
158
+ allow(provider).to receive(:complete) { |_messages, **_opts, &block| block.call('chunk') }
159
+
160
+ provider.stream_chat(messages: [], model: model) { |chunk| seen << chunk }
161
+
162
+ expect(seen).to eq(['chunk'])
163
+ end
164
+
165
+ it 'converts live list_models results into model offerings' do
166
+ offerings = provider.discover_offerings(live: true)
167
+ offering = offerings.first
168
+
169
+ expect(offerings.size).to eq(1)
170
+ expect(offering.provider_family).to eq(:provider)
171
+ expect(offering.provider_instance).to eq(:primary)
172
+ expect(offering.model).to eq('test-model')
173
+ expect(offering.usage_type).to eq(:inference)
174
+ expect(offering.capabilities).to include(:completion, :streaming, :tools)
175
+ expect(offering.context_window).to eq(8192)
176
+ end
177
+
178
+ it 'passes live discovery filters through to list_models' do
179
+ provider.discover_offerings(live: true, capability: :tools, instance: :primary)
180
+
181
+ expect(provider.list_model_calls).to include(
182
+ live: true,
183
+ filters: { capability: :tools, instance: :primary }
184
+ )
185
+ end
186
+
187
+ it 'filters generated offerings by capability and instance' do
188
+ provider.discover_offerings(live: true)
189
+
190
+ expect(provider.discover_offerings(capability: :tools, instance: :primary)).not_to be_empty
191
+ expect(provider.discover_offerings(capability: :embedding)).to be_empty
192
+ expect(provider.discover_offerings(instance: :other)).to be_empty
193
+ end
194
+
195
+ it 'does not perform live discovery for uncached non-live offerings reads' do
196
+ allow(provider).to receive(:list_models).and_raise('unexpected live discovery')
197
+
198
+ expect(provider.discover_offerings).to eq([])
199
+ expect(provider).not_to have_received(:list_models)
200
+ end
201
+
202
+ it 'serves non-live offerings reads from the live discovery cache' do
203
+ provider.discover_offerings(live: true)
204
+ allow(provider).to receive(:list_models).and_raise('unexpected live discovery')
205
+
206
+ expect(provider.discover_offerings(capability: :tools, instance: :primary)).not_to be_empty
207
+ end
208
+
209
+ it 'returns normalized health metadata' do
210
+ expect(provider.health).to include(
211
+ provider: :provider,
212
+ instance_id: :primary,
213
+ status: 'healthy',
214
+ ready: true,
215
+ circuit_state: 'closed'
216
+ )
217
+ end
218
+
219
+ it 'provides a deterministic token estimate fallback' do
220
+ expect(provider.count_tokens(messages: [{ content: 'hello world' }], model: model)).to be >= 1
221
+ end
222
+
223
+ it 'summarizes hash-backed tools for debug logging' do
224
+ tools = {
225
+ current: { name: 'current' },
226
+ legacy: { 'name' => 'legacy' }
227
+ }
228
+
229
+ expect(provider.send(:debug_tool_names, tools)).to eq(%w[current legacy])
230
+ end
231
+
232
+ it 'deep merges embedding params into the provider payload' do
233
+ captured_payload = nil
234
+ response = instance_double(Faraday::Response)
235
+ connection = instance_double(Legion::Extensions::Llm::Connection)
236
+ embedding_provider_class = Class.new(described_class) do
237
+ def api_base = 'https://contract.invalid'
238
+ def embedding_url(model:) = "/v1/#{model}/embeddings"
239
+
240
+ def render_embedding_payload(text, model:, dimensions:)
241
+ {
242
+ model: model,
243
+ input: text,
244
+ options: {
245
+ dimensions: dimensions,
246
+ normalize: false
247
+ }
248
+ }
249
+ end
250
+
251
+ def parse_embedding_response(response, model:, text:)
252
+ [response, model, text]
253
+ end
254
+ end
255
+ embedding_provider = embedding_provider_class.new(provider.config)
256
+ embedding_provider.instance_variable_set(:@connection, connection)
257
+
258
+ allow(connection).to receive(:post) do |_url, payload, &_block|
259
+ captured_payload = payload
260
+ response
261
+ end
262
+
263
+ result = embedding_provider.embed(
264
+ text: 'hello',
265
+ model: 'embed-model',
266
+ dimensions: 1024,
267
+ params: {
268
+ options: { normalize: true },
269
+ encoding_format: 'float'
270
+ }
271
+ )
272
+
273
+ expect(result).to eq([response, 'embed-model', 'hello'])
274
+ expect(connection).to have_received(:post).with('/v1/embed-model/embeddings', kind_of(Hash))
275
+ expect(captured_payload).to eq(
276
+ model: 'embed-model',
277
+ input: 'hello',
278
+ options: {
279
+ dimensions: 1024,
280
+ normalize: true
281
+ },
282
+ encoding_format: 'float'
283
+ )
284
+ end
285
+ end
286
+
287
+ describe '#model_allowed?' do
288
+ let(:provider_class) do
289
+ Class.new(described_class) do
290
+ attr_writer :settings
291
+
292
+ def api_base = 'https://test.invalid'
293
+
294
+ def settings
295
+ @settings || {}
296
+ end
297
+ end
298
+ end
299
+
300
+ let(:provider) { provider_class.new(Legion::Extensions::Llm.config) }
301
+
302
+ context 'with no whitelist or blacklist' do
303
+ it 'allows all models' do
304
+ expect(provider.model_allowed?('gpt-5')).to be true
305
+ expect(provider.model_allowed?('claude-opus')).to be true
306
+ end
307
+ end
308
+
309
+ context 'with whitelist' do
310
+ before { provider.settings = { model_whitelist: %w[gpt claude] } }
311
+
312
+ it 'allows models matching whitelist patterns' do
313
+ expect(provider.model_allowed?('gpt-5')).to be true
314
+ expect(provider.model_allowed?('claude-opus-4')).to be true
315
+ end
316
+
317
+ it 'blocks models not matching whitelist patterns' do
318
+ expect(provider.model_allowed?('llama-3')).to be false
319
+ end
320
+ end
321
+
322
+ context 'with blacklist' do
323
+ before { provider.settings = { model_blacklist: %w[deprecated preview] } }
324
+
325
+ it 'blocks models matching blacklist patterns' do
326
+ expect(provider.model_allowed?('gpt-5-preview')).to be false
327
+ expect(provider.model_allowed?('deprecated-model')).to be false
328
+ end
329
+
330
+ it 'allows models not matching blacklist patterns' do
331
+ expect(provider.model_allowed?('gpt-5')).to be true
332
+ end
333
+ end
334
+
335
+ context 'with both whitelist and blacklist' do
336
+ before do
337
+ provider.settings = {
338
+ model_whitelist: %w[gpt],
339
+ model_blacklist: %w[preview]
340
+ }
341
+ end
342
+
343
+ it 'applies whitelist first, then blacklist' do
344
+ expect(provider.model_allowed?('gpt-5')).to be true
345
+ expect(provider.model_allowed?('gpt-5-preview')).to be false
346
+ expect(provider.model_allowed?('llama-3')).to be false
347
+ end
348
+ end
349
+
350
+ context 'with case-insensitive matching' do
351
+ before { provider.settings = { model_whitelist: %w[GPT] } }
352
+
353
+ it 'matches case-insensitively' do
354
+ expect(provider.model_allowed?('GPT-5')).to be true
355
+ expect(provider.model_allowed?('gpt-5')).to be true
356
+ end
357
+ end
358
+ end
359
+
360
+ describe 'multi-host URL resolution' do
361
+ let(:provider_class) do
362
+ Class.new(described_class) do
363
+ attr_writer :settings
364
+
365
+ def api_base = resolve_base_url || 'https://fallback.invalid'
366
+
367
+ def settings
368
+ @settings || {}
369
+ end
370
+ end
371
+ end
372
+
373
+ let(:provider) { provider_class.new(Legion::Extensions::Llm.config) }
374
+
375
+ describe '#config_base_url' do
376
+ it 'returns the base_url from settings' do
377
+ provider.settings = { base_url: 'http://localhost:11434' }
378
+ expect(provider.config_base_url).to eq('http://localhost:11434')
379
+ end
380
+
381
+ it 'returns nil when no settings' do
382
+ expect(provider.config_base_url).to be_nil
383
+ end
384
+ end
385
+
386
+ describe '#strip_scheme' do
387
+ it 'strips http scheme' do
388
+ expect(provider.strip_scheme('http://localhost:11434')).to eq('localhost:11434')
389
+ end
390
+
391
+ it 'strips https scheme' do
392
+ expect(provider.strip_scheme('https://api.example.com')).to eq('api.example.com')
393
+ end
394
+
395
+ it 'returns as-is when no scheme' do
396
+ expect(provider.strip_scheme('localhost:11434')).to eq('localhost:11434')
397
+ end
398
+ end
399
+
400
+ describe '#tls_enabled?' do
401
+ it 'returns false by default' do
402
+ expect(provider.tls_enabled?).to be false
403
+ end
404
+
405
+ it 'returns true when tls.enabled is true' do
406
+ provider.settings = { tls: { enabled: true } }
407
+ expect(provider.tls_enabled?).to be true
408
+ end
409
+
410
+ it 'returns false when tls.enabled is false' do
411
+ provider.settings = { tls: { enabled: false } }
412
+ expect(provider.tls_enabled?).to be false
413
+ end
414
+ end
415
+
416
+ describe '#resolve_base_url' do
417
+ it 'returns nil when no config_base_url' do
418
+ expect(provider.resolve_base_url).to be_nil
419
+ end
420
+
421
+ it 'returns the single URL when unreachable (falls back to first)' do
422
+ provider.settings = { base_url: 'unreachable.invalid:9999' }
423
+ allow(provider).to receive(:url_reachable?).and_return(false)
424
+
425
+ expect(provider.resolve_base_url).to eq('http://unreachable.invalid:9999')
426
+ end
427
+
428
+ it 'handles array of URLs and picks first reachable' do
429
+ provider.settings = { base_url: ['unreachable.invalid:9999', 'reachable.invalid:8080'] }
430
+ allow(provider).to receive(:url_reachable?).and_return(false, true)
431
+
432
+ result = provider.resolve_base_url
433
+ expect(result).to eq('http://reachable.invalid:8080')
434
+ end
435
+
436
+ it 'falls back to first URL if none are reachable' do
437
+ provider.settings = { base_url: ['a.invalid:1', 'b.invalid:2'] }
438
+ allow(provider).to receive(:url_reachable?).and_return(false, false)
439
+
440
+ expect(provider.resolve_base_url).to eq('http://a.invalid:1')
441
+ end
442
+ end
443
+
444
+ describe '#url_reachable?' do
445
+ it 'returns false for unreachable URLs' do
446
+ expect(provider.url_reachable?('http://unreachable.invalid:9999')).to be false
447
+ end
448
+ end
449
+ end
450
+
451
+ describe 'cache tier selection' do
452
+ let(:provider_class) do
453
+ Class.new(described_class) do
454
+ attr_writer :settings
455
+
456
+ def api_base = 'https://test.invalid'
457
+
458
+ def settings
459
+ @settings || {}
460
+ end
461
+ end
462
+ end
463
+
464
+ let(:provider) { provider_class.new(Legion::Extensions::Llm.config) }
465
+
466
+ describe '#cache_local_instance?' do
467
+ it 'returns true for localhost URLs' do
468
+ provider.settings = { base_url: 'http://localhost:11434' }
469
+ expect(provider.cache_local_instance?).to be true
470
+ end
471
+
472
+ it 'returns true for 127.0.0.1 URLs' do
473
+ provider.settings = { base_url: 'http://127.0.0.1:11434' }
474
+ expect(provider.cache_local_instance?).to be true
475
+ end
476
+
477
+ it 'returns true for ::1 URLs' do
478
+ provider.settings = { base_url: 'http://[::1]:11434' }
479
+ expect(provider.cache_local_instance?).to be true
480
+ end
481
+
482
+ it 'returns false for remote URLs' do
483
+ provider.settings = { base_url: 'https://api.openai.com' }
484
+ expect(provider.cache_local_instance?).to be false
485
+ end
486
+
487
+ it 'returns true if any URL in array is local' do
488
+ provider.settings = { base_url: ['https://api.openai.com', 'http://localhost:11434'] }
489
+ expect(provider.cache_local_instance?).to be true
490
+ end
491
+
492
+ it 'returns false when no base_url configured' do
493
+ expect(provider.cache_local_instance?).to be false
494
+ end
495
+ end
496
+
497
+ describe '#cache_instance_key' do
498
+ it 'returns instance_id for local instances' do
499
+ provider.settings = { base_url: 'http://localhost:11434' }
500
+ expect(provider.cache_instance_key).to eq('default')
501
+ end
502
+
503
+ it 'returns SHA256 prefix for remote instances' do
504
+ provider.settings = { base_url: 'https://api.openai.com' }
505
+ key = provider.cache_instance_key
506
+ expect(key.length).to eq(12)
507
+ expect(key).to match(/\A[0-9a-f]+\z/)
508
+ end
509
+
510
+ it 'produces deterministic keys for same URLs' do
511
+ provider.settings = { base_url: 'https://api.openai.com' }
512
+ key1 = provider.cache_instance_key
513
+ key2 = provider.cache_instance_key
514
+ expect(key1).to eq(key2)
515
+ end
516
+ end
517
+
518
+ describe '#model_cache_get' do
519
+ it 'returns nil when Legion::Cache is not defined' do
520
+ expect(provider.model_cache_get('key')).to be_nil
521
+ end
522
+ end
523
+
524
+ describe '#model_detail' do
525
+ it 'returns nil by default (no fetch_model_detail override)' do
526
+ expect(provider.model_detail('test-model')).to be_nil
527
+ end
528
+ end
529
+ end
530
+
531
+ describe 'prompt caching' do
532
+ let(:provider_class) do
533
+ Class.new(described_class) do
534
+ attr_writer :settings
535
+
536
+ def api_base = 'https://test.invalid'
537
+
538
+ def settings
539
+ @settings || {}
540
+ end
541
+ end
542
+ end
543
+
544
+ describe '#cache_enabled?' do
545
+ it 'returns true when llm_cache_enabled is true in config' do
546
+ Legion::Extensions::Llm.config.llm_cache_enabled = true
547
+ provider = provider_class.new(Legion::Extensions::Llm.config)
548
+
549
+ expect(provider.cache_enabled?).to be true
550
+ end
551
+
552
+ it 'returns false when llm_cache_enabled is false in config' do
553
+ Legion::Extensions::Llm.config.llm_cache_enabled = false
554
+ provider = provider_class.new(Legion::Extensions::Llm.config)
555
+
556
+ expect(provider.cache_enabled?).to be false
557
+ end
558
+
559
+ it 'returns false when llm_cache_enabled is not set on config' do
560
+ config = { request_timeout: 30, max_retries: 0, retry_interval: 0, retry_backoff_factor: 0,
561
+ retry_interval_randomness: 0 }
562
+ provider = provider_class.new(config)
563
+
564
+ expect(provider.cache_enabled?).to be false
565
+ end
566
+ end
567
+
568
+ describe '#cache_control_prefix_tokens' do
569
+ it 'returns the configured value when set' do
570
+ Legion::Extensions::Llm.config.cache_control_prefix_tokens = 6
571
+ provider = provider_class.new(Legion::Extensions::Llm.config)
572
+
573
+ expect(provider.cache_control_prefix_tokens).to eq(6)
574
+ end
575
+
576
+ it 'defaults to 4 when not explicitly set' do
577
+ Legion::Extensions::Llm.config.cache_control_prefix_tokens = 4
578
+ provider = provider_class.new(Legion::Extensions::Llm.config)
579
+
580
+ expect(provider.cache_control_prefix_tokens).to eq(4)
581
+ end
582
+
583
+ it 'defaults to 4 when config does not respond to the option' do
584
+ config = { request_timeout: 30, max_retries: 0, retry_interval: 0, retry_backoff_factor: 0,
585
+ retry_interval_randomness: 0 }
586
+ provider = provider_class.new(config)
587
+
588
+ expect(provider.cache_control_prefix_tokens).to eq(4)
589
+ end
590
+ end
591
+ end
592
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe Legion::Extensions::Llm::RegistryEventBuilder do
6
+ subject(:builder) { described_class.new(provider_family: :ollama) }
7
+
8
+ describe '#provider_family' do
9
+ it 'normalizes to a downcased symbol' do
10
+ b = described_class.new(provider_family: 'Anthropic')
11
+ expect(b.provider_family).to eq(:anthropic)
12
+ end
13
+ end
14
+
15
+ describe '#model_available' do
16
+ let(:model) do
17
+ Legion::Extensions::Llm::Model::Info.from_hash(
18
+ id: 'llama-3.1-8b',
19
+ name: 'Llama 3.1 8B',
20
+ provider: 'ollama',
21
+ capabilities: %w[completion streaming],
22
+ modalities: { input: %w[text], output: %w[text] },
23
+ context_window: 128_000,
24
+ max_output_tokens: 8192
25
+ )
26
+ end
27
+
28
+ let(:readiness) { { ready: true, configured: true } }
29
+
30
+ it 'builds a RegistryEvent with offering data' do
31
+ event = builder.model_available(model, readiness: readiness)
32
+ expect(event).to be_a(Legion::Extensions::Llm::Routing::RegistryEvent)
33
+ expect(event.event_type).to eq(:offering_available)
34
+ expect(event.offering.model).to eq('llama-3.1-8b')
35
+ expect(event.offering.provider_family).to eq(:ollama)
36
+ end
37
+
38
+ it 'includes model health from readiness' do
39
+ event = builder.model_available(model, readiness: readiness)
40
+ expect(event.health[:ready]).to be true
41
+ expect(event.health[:status]).to eq(:available)
42
+ end
43
+
44
+ it 'includes extension metadata' do
45
+ event = builder.model_available(model, readiness: readiness)
46
+ expect(event.metadata[:extension]).to eq(:llm_ollama)
47
+ expect(event.metadata[:provider]).to eq(:ollama)
48
+ end
49
+ end
50
+
51
+ describe '#readiness' do
52
+ it 'builds an available event when ready' do
53
+ event = builder.readiness({ ready: true, configured: true })
54
+ expect(event.event_type).to eq(:offering_available)
55
+ end
56
+
57
+ it 'builds an unavailable event when not ready' do
58
+ event = builder.readiness({ ready: false, configured: true })
59
+ expect(event.event_type).to eq(:offering_unavailable)
60
+ end
61
+
62
+ it 'preserves error details from health' do
63
+ event = builder.readiness({ ready: false, health: { error: 'ConnectionRefused', message: 'refused' } })
64
+ expect(event.health[:error_class]).to eq('ConnectionRefused')
65
+ expect(event.health[:error]).to eq('refused')
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe Legion::Extensions::Llm::RegistryPublisher do
6
+ subject(:publisher) { described_class.new(provider_family: :ollama, builder: builder) }
7
+
8
+ let(:builder) { instance_double(Legion::Extensions::Llm::RegistryEventBuilder) }
9
+
10
+ describe '#app_id' do
11
+ it 'includes the provider family' do
12
+ expect(publisher.app_id).to eq('lex-llm-ollama')
13
+ end
14
+ end
15
+
16
+ describe '#provider_family' do
17
+ it 'normalizes to a downcased symbol' do
18
+ pub = described_class.new(provider_family: 'Anthropic', builder: builder)
19
+ expect(pub.provider_family).to eq(:anthropic)
20
+ end
21
+ end
22
+ end